diff --git a/.github/scripts/playwright-summary.jq b/.github/scripts/playwright-summary.jq new file mode 100644 index 0000000000..034b582267 --- /dev/null +++ b/.github/scripts/playwright-summary.jq @@ -0,0 +1,41 @@ +def specs: [.. | objects | select(has("tests") and has("file"))]; +def dur: [.tests[].results[]?.duration // 0] | add; + +specs as $s +| ($s | map(select(any(.tests[]; .status == "unexpected")))) as $failed +| ($s | map(select(any(.tests[]; .status == "flaky")))) as $flaky +| ($s | map(select(any(.tests[]; .status == "skipped")))) as $skipped +| ($s | length) as $total +| ($s | map(dur) | add // 0 | . / 1000 | floor) as $cpu +| (if ($failed | length) > 0 then "❌" + elif ($flaky | length) > 0 then "⚠️" + else "✅" end) as $icon + +| "## \($icon) Integration tests\n\n" ++ "| Total | Passed | Flaky | Failed | Skipped | Test time |\n" ++ "|---|---|---|---|---|---|\n" ++ "| \($total) | \($total - ($failed|length) - ($flaky|length) - ($skipped|length)) " ++ "| \($flaky|length) | \($failed|length) | \($skipped|length) | \($cpu / 60 | floor)m |\n" + ++ (if ($failed | length) > 0 then + "\n### Failed\n\n" + + ($failed | map("- `\(.file):\(.line)` — \(.title)") | join("\n")) + "\n" + else "" end) + ++ (if ($flaky | length) > 0 then + "\n### Flaky (passed on retry)\n\n" + + ($flaky + | map({ t: "`\(.file):\(.line)` — \(.title)", + r: ([.tests[].results[]? | select(.status == "failed")] | length) }) + | sort_by(-.r) + | map("- \(.t) _(\(.r) \(if .r == 1 then "retry" else "retries" end))_") + | join("\n")) + "\n" + else "" end) + ++ (if $total > 0 then + "\n
Slowest specs\n\n" + + ($s | map({ t: "`\(.file)` — \(.title)", d: (dur / 1000 | floor) }) + | sort_by(-.d) | .[0:5] + | map("- \(.t) — \(.d)s") | join("\n")) + + "\n\n
\n" + else "" end) diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml index e785f2c84e..b31450ac60 100644 --- a/.github/workflows/build-bundle.yml +++ b/.github/workflows/build-bundle.yml @@ -17,15 +17,19 @@ on: required: true default: 'develop' +# Literal group name: under `workflow_call`, `github.workflow` resolves to the +# caller's workflow, which put this workflow and the other reusable one called +# by the same caller into a single shared group, and left a manual dispatch of +# the same ref in a group of its own, free to race on the same artifacts. concurrency: - group: ${{ github.workflow }}-${{ inputs.gh_ref }} + group: build-bundle-${{ inputs.gh_ref }} cancel-in-progress: true jobs: # ── 1. Decide whether there is anything to build ─────────────────────── check: name: Check current bundle - runs-on: penpot-runner-01 + runs-on: penpot-standar-runner timeout-minutes: 10 outputs: gh_ref: ${{ steps.vars.outputs.gh_ref }} @@ -75,7 +79,7 @@ jobs: # ── 2. Build and upload, only when needed ────────────────────────────── build: name: Build and Upload Penpot Bundle - runs-on: penpot-runner-01 + runs-on: penpot-standar-runner timeout-minutes: 90 needs: check if: needs.check.outputs.exists == 'false' @@ -116,7 +120,7 @@ jobs: # ── 3. Single failure notification for the whole workflow ───────────── notify: name: Notify failure - runs-on: penpot-runner-01 + runs-on: penpot-standar-runner timeout-minutes: 5 needs: [check, build] if: failure() diff --git a/.github/workflows/build-develop.yml b/.github/workflows/build-develop.yml index 2da1ab2a31..961ad1dca9 100644 --- a/.github/workflows/build-develop.yml +++ b/.github/workflows/build-develop.yml @@ -5,6 +5,10 @@ on: schedule: - cron: '16 5-20 * * 1-5' +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + jobs: build-bundle: uses: ./.github/workflows/build-bundle.yml @@ -19,7 +23,7 @@ jobs: with: gh_ref: "develop" - build-admin-console-docker: + build-docker-admin-console: uses: ./.github/workflows/build-docker-admin-console.yml secrets: inherit with: diff --git a/.github/workflows/build-docker-devenv.yml b/.github/workflows/build-docker-devenv.yml index b0340d329b..53de6376ce 100644 --- a/.github/workflows/build-docker-devenv.yml +++ b/.github/workflows/build-docker-devenv.yml @@ -6,7 +6,7 @@ on: jobs: build-and-push: name: Build and push DevEnv Docker image - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner steps: - name: Set common environment variables diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 0d03490194..b7bb794776 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -16,8 +16,12 @@ on: required: true default: 'develop' +# Literal group name: under `workflow_call`, `github.workflow` resolves to the +# caller's workflow, which put this workflow and the other reusable one called +# by the same caller into a single shared group, and left a manual dispatch of +# the same ref in a group of its own, free to race on the same artifacts. concurrency: - group: ${{ github.workflow }}-${{ inputs.gh_ref }} + group: build-docker-${{ inputs.gh_ref }} cancel-in-progress: true env: @@ -32,12 +36,12 @@ jobs: # ── 1. Resolve the build key and check the whole set at once ─────────── prepare: name: Prepare - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner timeout-minutes: 15 outputs: gh_ref: ${{ steps.vars.outputs.gh_ref }} bundle_version: ${{ steps.vars.outputs.bundle_version }} - build_key: ${{ steps.vars.outputs.build_key }} + sha: ${{ steps.vars.outputs.sha }} exists: ${{ steps.check.outputs.exists }} steps: @@ -55,6 +59,7 @@ jobs: run: | GH_REF="${{ inputs.gh_ref || github.ref_name }}" echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT + echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT BUNDLE_VERSION=$(aws s3api head-object \ --bucket ${{ secrets.S3_BUCKET }} \ @@ -63,15 +68,10 @@ jobs: --output text) echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT - # Image content = bundle + docker build context, so the build key - # combines both. - CTX_HASH=$(git rev-parse "HEAD:docker/images" | cut -c1-12) - echo "build_key=${BUNDLE_VERSION}-${CTX_HASH}" >> $GITHUB_OUTPUT - # The image set is a single block, so a single set-level check is # enough: `promote` drops a marker object in S3 only after every # image was built AND every branch tag was moved. Marker present - # means there is nothing at all to do for this build key. + # means there is nothing at all to do for this commit. - name: Check if this image set is already built id: check env: @@ -81,13 +81,13 @@ jobs: run: | if aws s3api head-object \ --bucket ${{ secrets.S3_BUCKET }} \ - --key "markers/images-${{ steps.vars.outputs.build_key }}" \ + --key "markers/images-sha-${{ steps.vars.outputs.sha }}" \ > /dev/null 2>&1; then echo "exists=true" >> $GITHUB_OUTPUT { echo "### ⏭️ Image set build skipped" echo "" - echo "The whole set was already built and promoted for \`${{ steps.vars.outputs.build_key }}\`." + echo "The whole set was already built and promoted for \`sha-${{ steps.vars.outputs.sha }}\`." } >> "$GITHUB_STEP_SUMMARY" else echo "exists=false" >> $GITHUB_OUTPUT @@ -97,7 +97,7 @@ jobs: # prune stale bundles while at it. mkdir -p "$BUNDLE_CACHE" find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true - ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.build_key }}.zip" + ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.bundle_version }}.zip" if [ ! -f "$ZIP" ]; then aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip" "$ZIP.$$.tmp" mv "$ZIP.$$.tmp" "$ZIP" @@ -107,7 +107,7 @@ jobs: # ── 2. One build per image, in parallel, only when needed ────────────── build: name: Build ${{ matrix.image }} - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner timeout-minutes: 60 needs: prepare if: needs.prepare.outputs.exists == 'false' @@ -169,7 +169,7 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} run: | - ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.build_key }}.zip" + ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.bundle_version }}.zip" if [ ! -f "$ZIP" ]; then echo "Bundle not found in host cache; falling back to S3." mkdir -p "$BUNDLE_CACHE" @@ -209,7 +209,7 @@ jobs: sbom: true # Immutable tag only; branch tags are moved atomically for the # whole image set by the `promote` job. - tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:build-${{ needs.prepare.outputs.build_key }} + tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:sha-${{ needs.prepare.outputs.sha }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache,mode=max @@ -220,7 +220,7 @@ jobs: # the S3 marker guarantees the branch tags were already moved. promote: name: Promote image set - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner timeout-minutes: 10 needs: [prepare, build] @@ -245,7 +245,7 @@ jobs: for image in $ALL_IMAGES; do docker buildx imagetools create \ -t "${{ secrets.DOCKER_REGISTRY }}/$image:${{ needs.prepare.outputs.gh_ref }}" \ - "${{ secrets.DOCKER_REGISTRY }}/$image:build-${{ needs.prepare.outputs.build_key }}" + "${{ secrets.DOCKER_REGISTRY }}/$image:sha-${{ needs.prepare.outputs.sha }}" done # The marker is written LAST: its presence certifies that all five @@ -257,17 +257,17 @@ jobs: AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} run: | echo "${{ github.run_id }}" | aws s3 cp - \ - "s3://${{ secrets.S3_BUCKET }}/markers/images-${{ needs.prepare.outputs.build_key }}" + "s3://${{ secrets.S3_BUCKET }}/markers/images-sha-${{ needs.prepare.outputs.sha }}" { echo "### ✅ Image set promoted" echo "" - echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`build-${{ needs.prepare.outputs.build_key }}\`." + echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.sha }}\`." } >> "$GITHUB_STEP_SUMMARY" # ── 4. Single failure notification for the whole workflow ───────────── notify: name: Notify failure - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner timeout-minutes: 5 needs: [prepare, build, promote] if: failure() diff --git a/.github/workflows/build-staging.yml b/.github/workflows/build-staging.yml index 2ae5ee13b0..1523e4d7df 100644 --- a/.github/workflows/build-staging.yml +++ b/.github/workflows/build-staging.yml @@ -5,6 +5,10 @@ on: schedule: - cron: '36 5-20 * * 1-5' +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + jobs: build-bundle: uses: ./.github/workflows/build-bundle.yml @@ -19,7 +23,7 @@ jobs: with: gh_ref: "staging" - build-admin-console-docker: + build-docker-admin-console: uses: ./.github/workflows/build-docker-admin-console.yml secrets: inherit with: diff --git a/.github/workflows/build-tag.yml b/.github/workflows/build-tag.yml index f488c911a7..aa6a2b8357 100644 --- a/.github/workflows/build-tag.yml +++ b/.github/workflows/build-tag.yml @@ -6,6 +6,12 @@ on: tags: - '*' +# Keyed by ref and never cancelling: pushing 2.17.2 shortly after 2.17.2-RC1 +# must not abort the release already in flight. +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: false + jobs: build-bundle: uses: ./.github/workflows/build-bundle.yml @@ -20,10 +26,18 @@ jobs: with: gh_ref: ${{ github.ref_name }} + build-docker-admin-console: + uses: ./.github/workflows/build-docker-admin-console.yml + secrets: inherit + with: + gh_ref: ${{ github.ref_name }} + notify: name: Notifications runs-on: ubuntu-24.04 - needs: build-docker + needs: + - build-docker + - build-docker-admin-console steps: - name: Notify Mattermost uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 @@ -37,7 +51,9 @@ jobs: publish-final-tag: if: ${{ !contains(github.ref_name, '-RC') && !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && contains(github.ref_name, '.') }} - needs: build-docker + needs: + - build-docker + - build-docker-admin-console uses: ./.github/workflows/release.yml secrets: inherit with: diff --git a/.github/workflows/build-tmp-tokens.yml b/.github/workflows/build-tmp-tokens.yml new file mode 100644 index 0000000000..838a97a617 --- /dev/null +++ b/.github/workflows/build-tmp-tokens.yml @@ -0,0 +1,24 @@ +name: _TMP TOKENS + +on: + workflow_dispatch: + schedule: + - cron: '46 5-20 * * 1-5' + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + +jobs: + build-bundle: + uses: ./.github/workflows/build-bundle.yml + secrets: inherit + with: + gh_ref: "hiru-tokens-in-libs" + + build-docker: + needs: build-bundle + uses: ./.github/workflows/build-docker.yml + secrets: inherit + with: + gh_ref: "hiru-tokens-in-libs" diff --git a/.github/workflows/plugins-deploy-package.yml b/.github/workflows/plugins-deploy-package.yml index cbc8e109cd..2666957893 100644 --- a/.github/workflows/plugins-deploy-package.yml +++ b/.github/workflows/plugins-deploy-package.yml @@ -34,7 +34,7 @@ permissions: jobs: deploy: - runs-on: penpot-runner-01 + runs-on: penpot-standar-runner steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/tests-backend.yml b/.github/workflows/tests-backend.yml index d7a3377772..02f839c062 100644 --- a/.github/workflows/tests-backend.yml +++ b/.github/workflows/tests-backend.yml @@ -32,7 +32,7 @@ jobs: test-backend: if: ${{ !github.event.pull_request.draft }} name: "Backend Tests" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner container: image: penpotapp/devenv:latest volumes: diff --git a/.github/workflows/tests-common.yml b/.github/workflows/tests-common.yml index 5996c82742..3164fe5089 100644 --- a/.github/workflows/tests-common.yml +++ b/.github/workflows/tests-common.yml @@ -30,7 +30,7 @@ jobs: test-common: if: ${{ !github.event.pull_request.draft }} name: "Common Tests" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner container: image: penpotapp/devenv:latest volumes: diff --git a/.github/workflows/tests-composable-suite.yml b/.github/workflows/tests-composable-suite.yml index 1ad48c0edf..3b1d68be21 100644 --- a/.github/workflows/tests-composable-suite.yml +++ b/.github/workflows/tests-composable-suite.yml @@ -38,7 +38,7 @@ jobs: composable-test-suite: if: ${{ !github.event.pull_request.draft }} name: "Run composable test suite (mocked backend)" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner container: image: penpotapp/devenv:latest volumes: diff --git a/.github/workflows/tests-exporter.yml b/.github/workflows/tests-exporter.yml new file mode 100644 index 0000000000..1ed37d95c8 --- /dev/null +++ b/.github/workflows/tests-exporter.yml @@ -0,0 +1,58 @@ +name: "CI: Exporter" + +defaults: + run: + shell: bash + +on: + pull_request: + paths: + - 'exporter/**' + - 'common/**' + + types: + - opened + - synchronize + - ready_for_review + + push: + branches: + - develop + - staging + + paths: + - 'exporter/**' + - 'common/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test-exporter: + if: ${{ !github.event.pull_request.draft }} + name: "Exporter Tests" + runs-on: penpot-runner-02 + container: + image: penpotapp/devenv:latest + volumes: + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Lint + working-directory: ./exporter + run: | + corepack enable; + corepack install; + pnpm install; + pnpm run check-fmt:clj + pnpm run lint:clj + + - name: Tests + working-directory: ./exporter + run: | + ./scripts/test diff --git a/.github/workflows/tests-frontend.yml b/.github/workflows/tests-frontend.yml index 14011a5110..58ba335e38 100644 --- a/.github/workflows/tests-frontend.yml +++ b/.github/workflows/tests-frontend.yml @@ -34,7 +34,7 @@ jobs: test-frontend: if: ${{ !github.event.pull_request.draft }} name: "Frontend Tests" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner container: image: penpotapp/devenv:latest volumes: diff --git a/.github/workflows/tests-integration.yml b/.github/workflows/tests-integration.yml index b028676aa7..343e0dcef6 100644 --- a/.github/workflows/tests-integration.yml +++ b/.github/workflows/tests-integration.yml @@ -5,11 +5,37 @@ defaults: shell: bash on: + workflow_dispatch: + inputs: + gh_ref: + description: 'Name of the branch or ref' + type: string + required: true + default: 'develop' + + shards: + description: 'Shard layout (JSON array)' + type: choice + required: true + default: '[1, 2, 3, 4]' + options: + - '[1, 2, 3, 4]' + - '[1, 2, 3, 4, 5, 6]' + - '[1, 2]' + - '[1]' + + workers: + description: 'Playwright workers per shard' + type: string + required: true + default: '2' + pull_request: paths: - 'frontend/**' - 'common/**' - 'render-wasm/**' + - '.github/workflows/tests-integration.yml' types: - opened @@ -25,25 +51,41 @@ on: - 'frontend/**' - 'common/**' - 'render-wasm/**' + - '.github/workflows/tests-integration.yml' concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.gh_ref || github.ref }} cancel-in-progress: true jobs: build-integration: if: ${{ !github.event.pull_request.draft }} name: "Build Integration Bundle" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner + timeout-minutes: 30 container: image: penpotapp/devenv:latest volumes: - /var/cache/github-runner/m2:/root/.m2 - /var/cache/github-runner/gitlib:/root/.gitlibs + outputs: + bundle_key: ${{ steps.vars.outputs.bundle_key }} + steps: + # An empty `ref` makes checkout fall back to its default (the PR merge + # ref on pull_request, the pushed ref on push). - name: Checkout repository uses: actions/checkout@v6 + with: + ref: ${{ inputs.gh_ref }} + + # The cache key must come from the SHA actually checked out: on a manual + # run `github.sha` points at the dispatching ref, not at `gh_ref`. + - name: Extract cache key + id: vars + run: | + echo "bundle_key=integration-bundle-$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - name: Build Bundle working-directory: ./frontend @@ -53,41 +95,156 @@ jobs: - name: Store Bundle Cache uses: actions/cache@v5 with: - key: "integration-bundle-${{ github.sha }}" + key: ${{ steps.vars.outputs.bundle_key }} path: frontend/resources/public test-integration: if: ${{ !github.event.pull_request.draft }} - name: "Integration Tests" - runs-on: penpot-runner-02 + name: "Integration Tests (${{ matrix.shard }})" + runs-on: penpot-extended-runner + timeout-minutes: 40 + + needs: build-integration + + strategy: + fail-fast: false + matrix: + shard: ${{ fromJSON(inputs.shards || '[1, 2, 3, 4]') }} + + container: + image: penpotapp/devenv:latest + volumes: + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs + - /var/cache/github-runner/ms-playwright:/ms-playwright + env: + PLAYWRIGHT_BROWSERS_PATH: /ms-playwright + + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + with: + ref: ${{ inputs.gh_ref }} + + - name: Restore Cache + uses: actions/cache/restore@v5 + with: + key: ${{ needs.build-integration.outputs.bundle_key }} + path: frontend/resources/public + + - name: Install deps + working-directory: ./frontend + run: | + corepack enable; + corepack install; + pnpm install --frozen-lockfile; + + # No-op once the shared volume is warm; keeps the first run working. + - name: Install Playwright Chromium + working-directory: ./frontend + run: pnpm exec playwright install chromium + + # `strategy.job-total` is the matrix size, so the shard denominator + # follows the `shards` input without being hardcoded. + - name: Run Tests + working-directory: ./frontend + env: + WORKERS: ${{ inputs.workers }} + run: | + WORKERS=${WORKERS:-2} + echo "Running shard ${{ matrix.shard }}/${{ strategy.job-total }} with $WORKERS workers" + pnpm exec playwright test --project default \ + --workers="$WORKERS" \ + --shard=${{ matrix.shard }}/${{ strategy.job-total }} \ + --reporter=blob + + - name: Upload blob report + uses: actions/upload-artifact@v7 + if: always() + with: + name: integration-blob-report-${{ matrix.shard }} + path: frontend/blob-report/ + overwrite: true + retention-days: 3 + + - name: Upload test result + uses: actions/upload-artifact@v7 + if: always() + with: + name: integration-tests-result-${{ matrix.shard }} + path: frontend/test-results/ + overwrite: true + if-no-files-found: ignore + retention-days: 3 + + merge-reports: + if: ${{ always() && !github.event.pull_request.draft && needs.test-integration.result != 'skipped' }} + name: "Merge Integration Reports" + runs-on: penpot-extended-runner + timeout-minutes: 15 + + needs: test-integration + container: image: penpotapp/devenv:latest volumes: - /var/cache/github-runner/m2:/root/.m2 - /var/cache/github-runner/gitlib:/root/.gitlibs - needs: build-integration - steps: - name: Checkout Repository uses: actions/checkout@v6 - - - name: Restore Cache - uses: actions/cache/restore@v5 with: - key: "integration-bundle-${{ github.sha }}" - path: frontend/resources/public + ref: ${{ inputs.gh_ref }} - - name: Run Tests + - name: Install deps working-directory: ./frontend run: | - ./scripts/test-e2e + corepack enable; + corepack install; + pnpm install --frozen-lockfile; - - name: Upload test result + - name: Download blob reports + uses: actions/download-artifact@v7 + with: + path: frontend/all-blob-reports + pattern: integration-blob-report-* + merge-multiple: true + + - name: Merge into HTML report + working-directory: ./frontend + env: + PLAYWRIGHT_JSON_OUTPUT_NAME: report.json + run: | + pnpm exec playwright merge-reports \ + --reporter=html,json,list ./all-blob-reports + + - name: Test summary + if: always() + working-directory: ./frontend + run: | + if [ ! -f report.json ]; then + echo "No report produced (all shards failed early)." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + jq -r -f ../.github/scripts/playwright-summary.jq report.json >> "$GITHUB_STEP_SUMMARY" + + # Kept for 30 days so flakiness rates can be aggregated across runs + # without scraping job logs. + - name: Upload JSON report uses: actions/upload-artifact@v7 if: always() with: - name: integration-tests-result - path: frontend/test-results/ + name: integration-json-report + path: frontend/report.json overwrite: true - retention-days: 3 + if-no-files-found: ignore + retention-days: 30 + + - name: Upload HTML report + uses: actions/upload-artifact@v7 + with: + name: integration-html-report + path: frontend/playwright-report/ + overwrite: true + retention-days: 7 diff --git a/.github/workflows/tests-library.yml b/.github/workflows/tests-library.yml index 4c84965f4c..a5b565a892 100644 --- a/.github/workflows/tests-library.yml +++ b/.github/workflows/tests-library.yml @@ -32,7 +32,7 @@ jobs: test-library: if: ${{ !github.event.pull_request.draft }} name: "Library Tests" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner container: image: penpotapp/devenv:latest volumes: diff --git a/.github/workflows/tests-mcp.yml b/.github/workflows/tests-mcp.yml index 6f8eadcadd..f51c56b8a3 100644 --- a/.github/workflows/tests-mcp.yml +++ b/.github/workflows/tests-mcp.yml @@ -1,4 +1,4 @@ -name: "MCP CI" +name: "CI: MCP" on: pull_request: @@ -28,7 +28,7 @@ jobs: test-mcp: if: ${{ !github.event.pull_request.draft }} name: "Test MCP" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner container: penpotapp/devenv:latest steps: diff --git a/.github/workflows/tests-plugin-api-suite.yml b/.github/workflows/tests-plugin-api-suite.yml index e589c5414c..de6e2080bb 100644 --- a/.github/workflows/tests-plugin-api-suite.yml +++ b/.github/workflows/tests-plugin-api-suite.yml @@ -53,7 +53,7 @@ jobs: api-test-suite-mocked: if: ${{ github.event_name != 'workflow_dispatch' && !github.event.pull_request.draft }} name: "Run Plugin API Test Suite (mocked)" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner container: image: penpotapp/devenv:latest volumes: @@ -95,7 +95,7 @@ jobs: # api-test-suite-live: # if: ${{ github.event_name == 'workflow_dispatch' }} # name: Run Plugin API Test Suite (live) - # runs-on: penpot-runner-02 + # runs-on: penpot-extended-runner # container: # image: penpotapp/devenv:latest # diff --git a/.github/workflows/tests-plugins.yml b/.github/workflows/tests-plugins.yml index 1415c73db7..b1bbdd0992 100644 --- a/.github/workflows/tests-plugins.yml +++ b/.github/workflows/tests-plugins.yml @@ -30,7 +30,7 @@ jobs: test-plugins: if: ${{ !github.event.pull_request.draft }} name: Plugins Runtime Linter & Tests - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner container: image: penpotapp/devenv:latest volumes: diff --git a/.github/workflows/tests-wasm.yml b/.github/workflows/tests-wasm.yml index 424d4f908f..9f26175554 100644 --- a/.github/workflows/tests-wasm.yml +++ b/.github/workflows/tests-wasm.yml @@ -30,7 +30,7 @@ jobs: test-render-wasm: if: ${{ !github.event.pull_request.draft }} name: "Render WASM Tests" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner container: image: penpotapp/devenv:latest volumes: diff --git a/.gitignore b/.gitignore index 76da22b35f..cc695a46e3 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ opencode.json !AGENTS.md !CODE_OF_CONDUCT.md !SECURITY.md +!HIGHLIGHTS.md /*.png /*.svg /*.sql @@ -58,6 +59,8 @@ opencode.json /docker/images/bundle* /exporter/target /exporter/.shadow-cljs +/exporter/resources/wasm/ +/exporter/src/app/wasm/shared.js /frontend/.storybook/preview-body.html /frontend/.storybook/preview-head.html /frontend/playwright-report/ @@ -88,6 +91,7 @@ opencode.json /blob-report/ /playwright/.cache/ /render-wasm/target/ +/media-processor/dist/ /**/node_modules /**/.yarn/* /.pnpm-store @@ -95,6 +99,7 @@ opencode.json /.idea *.iml /.claude +/CLAUDE.md /.playwright-mcp /.devenv/mcp/ /opencode.json diff --git a/.nvmrc b/.nvmrc index 87d8620cc6..3648bfc346 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v24.18.1 +v24.19.0 diff --git a/.opencode/agents/commiter.md b/.opencode/agents/commiter.md deleted file mode 100644 index a5e128e4d1..0000000000 --- a/.opencode/agents/commiter.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -name: commiter -description: Git commit assistant -mode: subagent -permission: - read: allow - glob: allow - grep: allow - edit: deny - webfetch: deny - websearch: deny - task: deny - skill: deny - lsp: deny - todowrite: deny - question: deny - external_directory: deny - bash: allow ---- - -## Role - -You are the Penpot commit assistant. You produce git commits that follow the -repository's commit conventions. You do not implement features, review code, or -push branches — you commit. - -## Required Reading - -Before drafting any commit, **read `.serena/memories/workflow/creating-commits.md` -end-to-end**. It is the authoritative source for the commit message format, the -emoji menu, subject/body limits, and the `AI-assisted-by` trailer. Follow it -exactly — do not improvise the format and do not restate its contents here. - -## Pre-commit Workflow - -1. **Stage the files** specified by the calling agent. Do not ask for - confirmation — the calling agent knows exactly which files to commit. -2. Run `git diff --staged` to review the content. If you see secrets (API - keys, tokens, passwords, private keys, `.env` values), debug prints, or - anything that does not match the stated intent, STOP and tell the user - before committing. -3. Following the format in the doc, draft the message and run - `git commit -m "" -m ""` (or `git commit -F -` if the body has - unusual characters). The `AI-assisted-by` trailer value is provided by the - calling agent — use it verbatim. - -## Constraints - -- Do not push. Pushing is a separate workflow handled by the user. -- Do not run `git reset`, `git checkout`, `git restore`, `git clean`, or `rm` — these are destructive operations. -- Do not pass `--author`. Author identity comes from the local git config. -- Do not amend a commit you did not create in this session, unless the user explicitly asks. -- Do not bypass pre-commit hooks (`--no-verify`) unless the user explicitly asks. -- Do not add untracked files that were not created in this session. -- Do not ask questions. The calling agent provides all necessary information. If something is unclear, proceed with what you know and note any assumptions in your response. diff --git a/.opencode/commands/implement-plan.md b/.opencode/commands/implement-plan.md index f6cb42f6b8..8ecd1bd537 100644 --- a/.opencode/commands/implement-plan.md +++ b/.opencode/commands/implement-plan.md @@ -1,13 +1,10 @@ --- -description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the commiter subagent +description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the create-commit skill agent: build --- -# Implement Plan - This command is run once a plan is ready (for example, from plan mode). Execute -the plan already prepared in the current session context — it does not take -extra arguments. Follow these steps in order. +the plan already prepared in the current session context. Follow these steps in order. ## 1. Create the issue @@ -32,12 +29,11 @@ Implement the prepared plan from the session context. Work methodically, keeping changes focused on what the issue requires. Do not commit — the commit happens in step 4. -## 4. Commit with the commiter subagent +## 4. Commit with the create-commit skill -After the implementation is complete, delegate the commit to the **`commiter`** -subagent. Give it a brief summary of what was implemented and why, the issue -reference (`issue-NNNN`), and the model name you are running as so it sets the -`AI-assisted-by` trailer correctly. The subagent owns the commit format and -conventions. +After the implementation is complete, load the **`create-commit`** skill and +follow its workflow to commit the changes. Provide a brief summary of what was +implemented and why, the issue reference (`issue-NNNN`), and the model name you +are running as so the `AI-assisted-by` trailer is set correctly. Do not push. Pushing is handled separately by the user. diff --git a/.opencode/commands/resolve-git-conflicts.md b/.opencode/commands/resolve-git-conflicts.md new file mode 100644 index 0000000000..1b17ca0001 --- /dev/null +++ b/.opencode/commands/resolve-git-conflicts.md @@ -0,0 +1,40 @@ +--- +description: Resolve local git conflicts and stage the resolved files with git add — never continues the rebase +agent: build +--- + +# Fix Git Conflicts + +Resolve conflicts in the local repository. The user handles finishing the +rebase themselves — you must **never** run `git rebase --continue`, +`git rebase --skip`, `git merge --continue`, or anything similar. + +## Phase 1 — Understand the problem (read-only) + +1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files. +2. For each conflicted (unmerged) file, understand the situation **without modifying anything**: + - Read the file and identify the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`). + - Inspect both sides — `git show :` and `git show :` — plus `git log`/`git show` on the commits involved to understand intent. + - Identify what each side changed and why, and how they should be combined. + +## Phase 2 — Present the resolution plan + +3. **Present a clear plan to the user before touching any file.** For each conflicted file, state: + - What each side changed and why. + - Your proposed resolution and the reasoning behind it. + - How the two sides are combined (both additive → merge; both modify the same code → keep the semantically correct version, merging intent from both sides when clear from code and context). +4. **Ask the user only when genuinely unclear.** Do not ask about anything you can determine yourself from the code, commit messages, or context. Only decisions that are not determinable and change the outcome (e.g. conflicting product decisions, which side to discard) warrant a question. **Collect all such questions together in an "Open Questions" section at the end of the plan**, so the user has full context to answer them properly. +5. **Wait for the user to accept the plan** (and answer any open questions) before editing, staging, or otherwise modifying anything. + +## Phase 3 — Execute + +6. Resolve each conflicted file by editing the file to the agreed merged content and removing all conflict markers. + +## Phase 4 — Stage and verify + +7. **Stage every resolved file** with `git add `. Do not stage unrelated untracked files unless clearly part of the resolution. +8. Verify no conflict markers remain (search for `<<<<<<<` / `>>>>>>>` in resolved files) and that `git status` shows no unmerged paths. + +## Phase 5 — Report + +9. Briefly report the conflict state, how each conflicted file was resolved (and any answers received to open questions), and stop — do **not** run `git rebase --continue` or any other continuation command. diff --git a/.opencode/commands/review.md b/.opencode/commands/review.md deleted file mode 100644 index 3e6f59cee5..0000000000 --- a/.opencode/commands/review.md +++ /dev/null @@ -1,79 +0,0 @@ -Act as a senior software engineer and perform a thorough code review. - -## Instructions - -1. Load the **`code-review-and-quality`** skill — it defines the five axes, core principles (DRY, KISS, YAGNI), severity taxonomy, and output format. -2. Determine the diff or code to review from the provided context. -3. **Skip generated files, lockfile-only changes, and unrelated modifications** unless they introduce security risks. -4. Read the diff and the surrounding context for each changed file. -5. Review across all five axes: correctness, readability, architecture, security, performance. -6. Produce the review using this structure: - - **Summary**: One-paragraph overview of the change and its impact - - **Critical/High Findings**: Blockers that must be fixed (with file:line, severity, description, and proposed fix) - - **Other Findings**: Medium/Low issues and suggestions - - **Testing Recommendations**: Missing test coverage or test quality issues - - **Positive Observations**: What was done well (brief, specific) - - **Verdict**: Approve / Request Changes / Needs Discussion -7. For each finding: - - State the severity (Critical / High / Medium / Low / Suggestion) - - Identify the file and line - - Describe failure circumstances - - **For Critical/High**: Provide a concrete fix with a code snippet showing the corrected code - - **For Medium/Low**: Describe the fix clearly; code snippet optional - - If multiple approaches exist, briefly note trade-offs -8. **Perform a second review pass if the change is complex:** - - **Complex indicators**: Critical/High findings, multiple files (>5), architectural changes, security-sensitive code, >300 lines changed - - **Skip for simple changes**: Typo fixes, formatting, small bug fixes (<50 lines), single-file changes with no findings - - Second pass checks: - - Validate severity assignments: Are Critical/High findings truly blockers? - - Catch missed issues: Edge cases, error paths, test gaps overlooked in first pass - - Remove false positives: Discard findings that aren't real issues - - Verify fixes: Are the proposed solutions actually correct and complete? - -## Strong Rules - -1. Do not invent problems. Every finding must be real and actionable. -2. Do not modify any code and do not create a commit — this command only reviews. -3. Be specific and constructive. "This could be better" is not helpful — explain why and how. -4. Prioritize by impact. One structural issue outweighs ten nits. -5. If tests are missing for new functionality, flag it as High severity. - -## Context - -$ARGUMENTS - -## Expected Format - -``` -## Review Summary -[1-2 sentences on what the change does and overall assessment] - -## Critical/High Findings -### [Severity] file.ts:123 -**Issue**: [Description of the problem] -**Impact**: [What could go wrong] -**Fix**: -```[language] -// Current code -[problematic code] - -// Fixed code -[corrected code] -``` -[Optional: note trade-offs if multiple approaches exist] - -## Other Findings -### [Severity] file.ts:456 -**Issue**: [Description] -**Fix**: [Clear description; code snippet optional] - -## Testing Recommendations -[List specific test cases that should be added] - -## Positive Observations -[2-3 specific things done well] - -## Verdict -[Approve / Request Changes / Needs Discussion] -[If Request Changes: list the must-fix items] -``` diff --git a/.opencode/skills/code-review-and-quality/SKILL.md b/.opencode/skills/code-review/SKILL.md similarity index 91% rename from .opencode/skills/code-review-and-quality/SKILL.md rename to .opencode/skills/code-review/SKILL.md index a0f75e7f99..7fa581efa0 100644 --- a/.opencode/skills/code-review-and-quality/SKILL.md +++ b/.opencode/skills/code-review/SKILL.md @@ -1,5 +1,5 @@ --- -name: code-review-and-quality +name: code-review description: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch. --- @@ -106,6 +106,8 @@ For detailed security guidance, see `security-and-hardening`. | **Low:** | Minor, optional | Author may ignore — formatting, style preferences | | **Suggestion:** | Worth considering | Not required, but improves the code | +**Unique finding IDs.** Assign every finding a stable identifier: `F1`, `F2`, `F3`, … numbered in order of severity (Critical first, then High, Medium, Low, Suggestion). Use the ID everywhere the finding is mentioned — in section headers, in the verdict, in follow-up discussion. Never renumber within a review. Example: `**F3 (High)** — `app/validate.cljs:42` — duplicate branch logic…`. + For each finding, describe the circumstances under which it could fail: specific inputs, load conditions, timing, or user actions that trigger the problem. "This crashes when input is null" is actionable; "this might crash" is not. Lead with what matters: correctness and security first, then structural issues, then everything else. A few high-conviction comments beat a long list. @@ -122,11 +124,11 @@ Briefly explain what the code does and give an overall assessment. ### Critical and High-Priority Issues -List problems that could cause security incidents, data loss, crashes, incorrect behavior, or major performance degradation. For each: state the severity, identify the file/function/code section, explain why it's a problem, describe failure circumstances, and provide a concrete improvement with corrected code when useful. +List problems that could cause security incidents, data loss, crashes, incorrect behavior, or major performance degradation. Each finding gets its unique ID (`F1`, `F2`, …). For each: state the severity, identify the file/function/code section, explain why it's a problem, describe failure circumstances, and provide a concrete improvement with corrected code when useful. ### Other Findings -List medium- and low-priority issues, including maintainability and design concerns. +List medium- and low-priority issues, including maintainability and design concerns. Continue the ID sequence started above (`F3`, `F4`, …). ### Suggested Refactoring @@ -148,6 +150,8 @@ Choose one: - **Approve with minor changes** — Good to merge after addressing low/medium issues - **Request changes** — Critical or high issues must be resolved before merge +List the finding IDs the verdict depends on (e.g. "Request changes: F1, F4"). + ## Change Sizing Small, focused changes are easier to review, faster to merge, and safer to deploy. @@ -231,25 +235,13 @@ For supply-chain risk triage, follow the `security-and-hardening` skill. ## Verification -After review is complete: +Before emitting the verdict, verify the change as it stands. This is the reviewer's own due diligence — it covers the state of the code at review time, not the later resolution of findings (fixing findings is the author's job; confirming them is a new review): -- [ ] All Critical issues are resolved -- [ ] All Required (no-prefix) changes are resolved or explicitly deferred with justification -- [ ] Tests pass +- [ ] Tests pass — run them yourself, don't trust the claim - [ ] Build succeeds - [ ] The verification story is documented (what changed, how it was verified) - [ ] Dependency upgrades reviewed against changelog, isolated per package, verified by green suite -## Multi-Model Review Pattern - -Use different models for different review perspectives: - -``` -Model A writes the code → Model B reviews → Model A addresses feedback → Human makes the final call -``` - -Different models have different blind spots. - ## See Also - For detailed security review guidance, see `security-and-hardening` diff --git a/.opencode/skills/create-commit/SKILL.md b/.opencode/skills/create-commit/SKILL.md new file mode 100644 index 0000000000..790ba1b585 --- /dev/null +++ b/.opencode/skills/create-commit/SKILL.md @@ -0,0 +1,47 @@ +--- +name: create-commit +description: Stage, review, and commit files following Penpot commit conventions. +--- + +# Skill: create-commit + +Produce a git commit that follows Penpot's commit message conventions. This +skill owns the commit format, staging review, and safety checks — it does not +implement features or push. + +## When to Use + +- After code changes are complete and files need to be committed +- When delegated by a workflow step (e.g. implement-plan) to handle the commit + +## Required Reading + +Before drafting any commit, read `mem:workflow/creating-commits` end-to-end. It +is the authoritative source for the commit message format, the emoji menu, +subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly. + +## Workflow + +1. **Stage the files** specified by the calling context. Do not ask for + confirmation. +2. Run `git diff --staged` to review the content. If you see secrets (API keys, + tokens, passwords, private keys, `.env` values), debug prints, or anything + that does not match the stated intent, **STOP** and tell the user before + committing. +3. Draft the message following the format in the memory doc, wrapping the body + at 72 characters per line, and run: + ```bash + git commit -m "" -m "" + ``` + (or `git commit -F -` if the body has unusual characters). +4. The `AI-assisted-by` trailer value is provided by the calling context — use + it verbatim. + +## Constraints + +- Do not push. Pushing is a separate workflow handled by the user. +- Do not run `git reset`, `git checkout`, `git restore`, `git clean`, or `rm`. +- Do not pass `--author`. Author identity comes from the local git config. +- Do not amend a commit you did not create in this session, unless explicitly asked. +- Do not bypass pre-commit hooks (`--no-verify`) unless explicitly asked. +- Do not add untracked files that were not created in this session. diff --git a/.opencode/skills/plan-review/SKILL.md b/.opencode/skills/plan-review/SKILL.md new file mode 100644 index 0000000000..4386d701cc --- /dev/null +++ b/.opencode/skills/plan-review/SKILL.md @@ -0,0 +1,315 @@ +--- +name: plan-review +description: Reviews implementation plans for quality, completeness, and actionability. Use after a plan is produced by the planner skill, before starting implementation. Use when evaluating a plan written by yourself, another agent, or a human. +--- + +# Plan Review + +## Overview + +Multi-dimensional plan review with quality gates. Every plan gets reviewed before implementation starts — no exceptions. Review covers six axes: completeness, task quality, architecture & sequencing, risk coverage, actionability, and proposed code quality. + +**The approval standard:** Approve a plan when it is specific enough that a skilled implementer could execute it without guessing, the task ordering is sound, and risks are acknowledged. Perfect plans don't exist — the goal is confidence that implementation won't derail. Don't block a plan because it isn't exactly how you would have structured it. If it's executable and well-organized, approve it. + +## When to Use + +- After the planner skill produces a plan +- Before starting implementation on any non-trivial task +- When reviewing a plan written by another agent or a human +- When a plan feels too large, vague, or risky to start + +**Do NOT use for:** Single-file changes with obvious scope, or when the task is trivial enough to just do. + +## The Six-Axis Review + +Every plan gets evaluated across these dimensions: + +### 1. Completeness + +Does the plan cover everything needed to implement successfully? + +- Is the **context** clear? (What problem, why now, what's the goal?) +- Are **affected modules** identified with paths? +- Are **architecture decisions** documented with rationale? +- Is there a **testing strategy**? +- Are **verification commands** explicit (not "run the tests")? +- Are **open questions** listed (not buried in someone's head)? +- Is there a **parallelization** assessment for multi-task plans? + +**Missing any of these is a gap, not a nit.** + +### 2. Task Quality + +Are the tasks well-defined and independently executable? + +- Does every task have **acceptance criteria**? (Testable, not vague) +- Does every task have **verification steps**? +- Are tasks **sized appropriately**? (XS–M is ideal, L is acceptable, XL must be split) +- Are **dependencies** between tasks explicitly stated? +- Are **files likely touched** listed? +- Is each task a **single, self-contained change**? (Not "implement the whole feature") +- Could a skilled implementer pick up any task and execute it without asking clarifying questions? + +### 3. Architecture & Sequencing + +Is the plan structured so implementation flows correctly? + +- Does implementation order follow the **dependency graph** (foundations first)? +- Are tasks **vertically sliced** (feature paths) rather than horizontally layered? +- Does each task leave the system in a **working state**? +- Are there **checkpoints** between major phases? +- Are **high-risk tasks early** (fail fast)? +- Is the total plan a reasonable number of tasks? (More than ~15 tasks suggests the scope should be split into multiple plans) + +### 4. Risk Coverage + +Are the hard parts acknowledged and mitigated? + +- Are **edge cases** identified? +- Are **breaking changes** or **migration concerns** noted? +- Are **security implications** considered? +- Are **performance implications** considered? +- Are **external dependencies** or integration risks flagged? +- Is there a plan for **rollback** if something goes wrong? +- Are **data integrity** risks addressed (what happens if a migration fails mid-way)? + +### 5. Actionability + +Can an implementer actually execute this? + +- Are **file paths** specific (not "update the relevant files")? +- Are **function/method names** mentioned where applicable? +- Are **verification commands** copy-pasteable (not "run the linter")? +- Are **test commands** project-specific (not generic)? +- Is the **code shape** described where the implementation isn't obvious? +- Are **conventions** referenced (naming, patterns, existing utilities to reuse)? +- Does the plan reference **existing code** the implementer should read first? + +### 6. Proposed Code Quality *(when the plan includes implementation details)* + +If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review` criteria: + +- **Correctness:** Do the proposed types/signatures handle edge cases (null, empty, boundaries)? +- **Readability:** Are proposed names descriptive and consistent with project conventions? +- **Architecture:** Do proposed abstractions follow existing patterns? Are they justified (not over-engineered)? +- **Security:** Do proposed APIs validate input at boundaries? Any injection/XSS vectors in the design? +- **Performance:** Do proposed data structures avoid N+1 patterns? Any unbounded operations in the design? + +**When to apply:** Only when the plan includes specific code snippets, type definitions, API contracts, or function signatures. Plans that only describe "what" without showing "how" skip this axis. + +## Structural Remedies + +When you flag a structural problem in a plan, propose the fix — not just the problem: + +- **A task is too large (XL):** Split it into vertical slices. Each slice should be independently testable. +- **Missing acceptance criteria:** Draft 2–3 specific, testable conditions for the task. +- **Wrong sequencing:** Identify the dependency and propose the correct order. +- **No checkpoints:** Suggest where checkpoints should go (typically after every 2–3 tasks). +- **Vague verification:** Replace "run tests" with the actual project command. +- **Horizontal slicing:** Restructure into vertical feature paths. +- **Missing risk section:** Draft the risks you can identify from the plan content. + +Prefer the remedy that makes the plan immediately actionable over one that just flags the gap. + +## Plan Sizing + +Plans should be scoped to a single deliverable: + +``` +1–5 tasks → Good. A focused feature or bug fix. +6–10 tasks → Acceptable for a moderate feature. +11–15 tasks → Large. Consider splitting into phases. +15+ tasks → Too large. Split into multiple plans. +``` + +**What counts as "one plan":** A self-contained set of changes that delivers a single coherent capability. If you can describe the goal in one sentence, it's one plan. + +## Categorize Findings + +Label every comment with its severity so the author knows what's required vs optional: + +| Prefix | Meaning | Author Action | +|--------|---------|---------------| +| *(no prefix)* | Required change | Must address before implementation starts | +| **Critical:** | Blocks implementation | Missing security consideration, data integrity risk, fundamentally wrong approach | +| **Nit:** | Minor, optional | Author may ignore — wording, formatting | +| **Optional:** / **Consider:** | Suggestion | Worth considering but not required | +| **FYI** | Informational only | No action needed — context for future reference | + +**Lead with what matters.** Order findings by leverage: missing risks and wrong sequencing first, then task quality gaps, then completeness, then nits. If you have one critical sequencing problem and ten nits, the sequencing problem *is* the review. + +## Review Process + +### Step 1: Understand the Goal + +Before evaluating structure, understand intent: + +``` +- What is this plan trying to accomplish? +- What problem does it solve? +- What does "done" look like? +``` + +### Step 2: Check Completeness First + +Scan for missing sections before diving into content: + +``` +- Context present? +- Affected modules listed? +- Architecture decisions documented? +- Risks acknowledged? +- Testing strategy defined? +- Verification commands explicit? +``` + +### Step 3: Review Task Quality + +Walk through each task: + +``` +For each task: +1. Can I tell exactly what to build? +2. Are acceptance criteria specific and testable? +3. Is the size reasonable (not XL)? +4. Are dependencies clear? +5. Would I know which files to touch? +``` + +### Step 4: Validate Sequencing + +Check the dependency graph: + +``` +- Are foundations built first? +- Does each task leave the system working? +- Are checkpoints placed correctly? +- Are high-risk items early? +- Is it vertically sliced? +``` + +### Step 5: Assess Actionability + +Put yourself in the implementer's shoes: + +``` +- Could I pick up task 1 and start coding without asking any questions? +- Are the verification commands copy-pasteable? +- Are file paths and function names specific? +- Is existing code referenced where I'd need to read it? +``` + +### Step 6: Verify the Verification Story + +Check that the plan can actually confirm it worked: + +``` +- What tests should pass after implementation? +- What build/compile commands are relevant? +- What manual checks are needed? +- How do we know the feature works end-to-end? +``` + +### Step 7: Evaluate Proposed Code Quality *(if applicable)* + +If the plan includes code snippets, types, or API designs: + +``` +- Load code-review skill for criteria +- Check proposed signatures for edge cases +- Verify naming follows project conventions +- Confirm abstractions follow existing patterns +- Scan for security vectors in proposed APIs +- Check for performance issues in proposed data structures +``` + +## Review Checklist + +```markdown +## Review: [Plan title] + +### Completeness +- [ ] Context explains the problem and goal +- [ ] Affected modules are listed with paths +- [ ] Architecture decisions have rationale +- [ ] Testing strategy is defined +- [ ] Verification commands are explicit and project-specific +- [ ] Open questions are listed + +### Task Quality +- [ ] Every task has acceptance criteria +- [ ] Every task has verification steps +- [ ] Tasks are sized XS–M (L acceptable, XL must be split) +- [ ] Task dependencies are stated +- [ ] Files likely touched are listed + +### Architecture & Sequencing +- [ ] Order follows dependency graph (foundations first) +- [ ] Vertically sliced (not horizontal layers) +- [ ] Each task leaves system working +- [ ] Checkpoints exist between phases +- [ ] High-risk tasks are early + +### Risk Coverage +- [ ] Edge cases identified +- [ ] Breaking changes / migrations noted +- [ ] Security implications considered +- [ ] Performance implications considered +- [ ] Rollback strategy exists (if applicable) + +### Actionability +- [ ] File paths are specific +- [ ] Verification commands are copy-pasteable +- [ ] Existing code to read is referenced +- [ ] Conventions and patterns are noted + +### Proposed Code Quality *(if plan includes implementation details)* +- [ ] Proposed types/signatures handle edge cases +- [ ] Proposed names follow project conventions +- [ ] Proposed abstractions follow existing patterns +- [ ] No security vectors in proposed APIs +- [ ] No performance issues in proposed structures + +### Verdict +- [ ] **Approve** — Ready to implement +- [ ] **Request changes** — Gaps must be addressed +``` + +## Common Rationalizations + +| Rationalization | Reality | +|---|---| +| "I'll figure out the details during implementation" | That's how you discover blocking dependencies mid-task. Surface them now. | +| "The tasks are obvious, no need for criteria" | Write them anyway. Explicit criteria surface hidden assumptions. | +| "It's just a small feature, it doesn't need a plan" | Small features have edge cases too. 3 tasks with criteria takes 5 minutes. | +| "The plan is good enough" | "Good enough" without acceptance criteria means the implementer defines "done" — and they might define it differently. | +| "I'll add verification steps later" | Later never comes. The plan is the contract — define verification now. | +| "Risks are minimal" | Every change has risks. If you can't name them, you haven't thought about them. | +| "The file paths are obvious" | They're obvious to the author. The implementer might not know the codebase. | +| "The code in the plan is fine, it'll get reviewed later" | Plan-level code review catches design problems before implementation — fixing them after coding is more expensive. | + +## Red Flags + +- No acceptance criteria on any task +- Tasks that say "implement the feature" without specifics +- No verification steps anywhere in the plan +- All tasks are XL-sized +- No checkpoints between phases +- Dependency order isn't considered (e.g., API handler before domain model) +- No testing strategy +- Verification commands are generic ("run tests") instead of project-specific +- Plan has 20+ tasks (scope too large for one plan) +- No risk section on a plan with migrations, breaking changes, or security implications +- Horizontal slicing (all domain, then all services, then all API) +- File paths are vague ("update the relevant files") +- Missing open questions section despite stated unknowns +- Proposed code ignores project conventions or existing patterns +- Proposed types use gratuitous `any`/`unknown`/optional without justification +- Proposed APIs don't validate input at boundaries + +## See Also + +- For producing plans, use the `planner` skill +- For reviewing implemented code, use `code-review` — also the criteria source for axis 6 +- For security-specific concerns, see `security-and-hardening` +- For testing strategy guidance, see `testing` diff --git a/.opencode/skills/planner/SKILL.md b/.opencode/skills/planner/SKILL.md index d1802652e2..3598a0dc16 100644 --- a/.opencode/skills/planner/SKILL.md +++ b/.opencode/skills/planner/SKILL.md @@ -1,13 +1,13 @@ --- name: planner -description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan (Context, Affected modules, Approach, Risks, Testing). Always output to the user; additionally save to .opencode/plans/YYYY-MM-DD-.md. +description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user and save to .opencode/plans/YYYY-MM-DD-<title>.md. --- # Planner Read-only senior software architect role for Penpot. Produces structured -implementation plans that engineers or other agents can execute. Never writes -or modifies code. +implementation plans with task breakdowns that engineers or other agents can +execute. Never writes or modifies code. ## When to Use @@ -18,24 +18,29 @@ or modifies code. - The user asks "how would I implement X?" or "what's involved in fixing Y?". - The user is about to start non-trivial work and wants a bite-sized task breakdown. +- A task feels too large or vague to start. +- Work needs to be parallelized across multiple agents or sessions. Do **not** use this skill to actually implement anything — it is read-only. +**When NOT to use:** Single-file changes with obvious scope, or when the spec +already contains well-defined tasks. + ## Role -You are a Senior Software Architect working on Penpot, an open-source design -tool. Your sole responsibility is planning and analysis — you do NOT write or -modify code. +You help users understand the Penpot codebase, design solutions, and produce +implementation plans that other agents or developers can execute. The plan +tells them what to build and how to verify it, task by task. -You help users understand the codebase, design solutions, and create detailed -implementation plans that other agents or developers can execute. Document -everything they need to know: which files to touch for each task, code patterns, -tests, and how to verify correctness. Apply DRY and KISS principles. +The implementer reads the project's agent docs (`AGENTS.md`, project memories +such as `mem:critical-info`, `mem:testing`, and each module's core memory) +before working. Reference those memories instead of re-explaining tooling, +conventions, or test design — explain in the plan only what they do not cover. Do **not** suggest commit messages or commit names anywhere in your plans or -responses — committing is the developer's responsibility. +responses — committing is the implementer's responsibility. -## Required Reading Before Planning +## CRITICAL: Required Reading Before Planning Before drafting any plan, work through the project's own guidance: @@ -50,6 +55,8 @@ Before drafting any plan, work through the project's own guidance: Skipping this step is the #1 cause of incorrect or incomplete plans. +--- + ## The Planning Process ### Phase 1: Architecture Analysis @@ -64,16 +71,42 @@ Skipping this step is the #1 cause of incorrect or incomplete plans. ### Phase 2: Task Breakdown -Implementation order follows the monorepo's dependency graph: -`frontend -> common`, `backend -> common`, `exporter -> common`, -`frontend -> render-wasm`. Build shared foundations first, then layer -consumers on top. +#### Identify the Dependency Graph + +Map what depends on what, following the monorepo's module dependency graph: + +``` +common (shared types, schemas — no deps) + │ + ├── backend (depends common) + │ ├── RPC handlers + │ └── persistence / migrations + │ + ├── frontend (depends common, render-wasm) + │ ├── UI components + │ └── state / API integration + │ + ├── exporter (depends common) + │ + └── render-wasm (consumed by frontend) +``` + +Implementation order follows the dependency graph bottom-up: build shared +foundations first, then layer consumers on top. #### Slice Vertically Instead of building all of common, then all of backend, then all of frontend — build one complete feature path at a time: +**Bad (horizontal slicing):** +``` +Task 1: Build all common types +Task 2: Build all backend handlers +Task 3: Build all frontend components +``` + +**Good (vertical slicing):** ``` Task 1: common data types + schema ← foundation Task 2: backend RPC handler + persistence @@ -89,39 +122,58 @@ Each task follows this structure: ```markdown ## Task [N]: [Short descriptive title] -**Description:** One paragraph explaining what this task accomplishes. +**Description:** One or two paragraphs explaining what this task accomplishes. +Should be clear and concise. + +**Rationale:** Why this task exists and why this approach over the obvious +alternatives — design decisions, trade-offs, constraints discovered during +analysis. One or two sentences; skip only if genuinely trivial. + +**Code sketch (optional):** Signature-, type-, or shape-level example when the +intended interface is non-obvious. Keep it short — a skeleton that fixes the +contract (function signature, model fields, error shape), never a full +implementation. Omit when the task is mechanical. **Acceptance criteria:** - [ ] [Specific, testable condition] - [ ] [Specific, testable condition] **Verification:** -- [ ] Tests pass (module-specific test command) -- [ ] Lint/formatter passes (module-specific check command) +- [ ] Relevant tests pass (module-specific test command). +- [ ] Lint/formatter passes (module-specific check command), if applicable. +- [ ] The core flow works end-to-end, if applicable. **Dependencies:** [Task numbers this depends on, or "None"] **Files likely touched:** - `path/to/file.clj` - `path/to/file_test.clj` + +**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files] ``` Replace "module-specific test command" with the actual commands for the module -(e.g. `clojure -M:dev:test` for backend/common, `npx shadow-cljs compile test && npx karma start` for frontend, -or the commands noted in the module's core memory). +(e.g. `clojure -M:dev:test` for backend/common, +`npx shadow-cljs compile test && npx karma start` for frontend, or the +commands noted in the module's core memory). + +When possible, design each task with TDD in mind: acceptance criteria double +as a test list, and the natural first step of the task is writing those tests +before the implementation. Some tasks resist this (config, migrations, pure +wiring) — for those, keep the usual verification steps. #### Estimate Scope -| Size | Files | Scope | -|------|-------|-------| -| **XS** | 1 | Single function, config change, or schema tweak | -| **S** | 1-2 | One handler or component method | -| **M** | 3-5 | One vertical feature slice | -| **L** | 5-8 | Multi-component feature | -| **XL** | 8+ | **Too large — break it down further** | +| Size | Files | Scope | Example | +|------|-------|-------|---------| +| **XS** | 1 | Single function, config change, or schema tweak | Add a validation rule | +| **S** | 1-2 | One handler or component method | Add a new RPC endpoint | +| **M** | 3-5 | One vertical feature slice | Bookmark CRUD with tests | +| **L** | 5-8 | Multi-component feature | Search with filtering and pagination | +| **XL** | 8+ | **Too large — break it down further** | — | -If a task is L or larger, break it into smaller tasks. Agents perform best on -S and M tasks. +If a task is XL, it should be broken into smaller tasks. Agents perform best +on S and M tasks. **When to break a task down further:** - It would take more than one focused session @@ -141,11 +193,11 @@ Arrange tasks so that: Add explicit checkpoints with the relevant module commands: ```markdown -## Checkpoint: After Tasks 1-3 -- [ ] All tests pass (module-specific command) -- [ ] Lint/format passes (module-specific command) -- [ ] Core flow works end-to-end -- [ ] Review with human before proceeding +### Checkpoint: After Tasks 1-3 +- [ ] Relevant tests pass (module-specific command). +- [ ] The relevant build or compilation passes, if applicable. +- [ ] The core flow works end-to-end. +- [ ] Review with human before proceeding. ``` ## Requirements @@ -159,7 +211,7 @@ Add explicit checkpoints with the relevant module commands: - Apply DRY and KISS principles to the proposed implementation. - Define a testing strategy aligned with each affected module's tooling. - Every task must have acceptance criteria and verification steps. -- Checkpoints must exist between major phases. +- Checkpoints must exist after every 2-3 tasks. ## Constraints @@ -168,7 +220,8 @@ Add explicit checkpoints with the relevant module commands: `.opencode/plans/`. - You do **not** run builds, tests, linters, or any commands that modify state. - You do **not** create git commits or interact with version control. -- You do **not** execute shell commands beyond read-only searches. +- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`, + `find`, `cat`, `bat`). - Your output is a structured plan or analysis, ready for handoff to an engineer agent or developer. @@ -188,8 +241,9 @@ slug is lowercase, hyphen-separated, and a short summary of the task (e.g. `add-batch-get-profiles-for-file-comments`). Create the `.opencode/plans/` directory if it does not exist. -Always attempt the write. If the user explicitly provides a target file path, -use that path instead of the default. +IMPORTANT: The plan agent has write permission specifically for +`.opencode/plans/` — always attempt the write. If the user explicitly provides +a target file path, use that path instead of the default. ### Plan Document Template @@ -212,41 +266,75 @@ use that path instead of the default. security implications.] ## Approach -[Step-by-step implementation plan with file paths, function names, and code -shape where applicable. Group steps into atomic, ordered tasks.] +[A short strategy summary: 3-5 sentences describing the overall approach and +the shape of the dependency graph (what depends on what, what gets built +first). High-level only — the task-by-task detail lives in the Task List.] ## Task List -### Phase 1: Foundation -- [ ] Task 1: ... -- [ ] Task 2: ... +Each task uses the full task structure defined in +[Write Tasks](#write-tasks) — description, rationale, acceptance criteria, +verification, dependencies, files, estimated scope, and optional code sketch. +Never reduce a task to a one-line checkbox; the plan must be self-contained +and executable without other context. -### Checkpoint: Phase 1 -- [ ] Tests pass, lint/formatter clean (module-specific commands) +Tasks are a flat, ordered list — a plan is not a roadmap. Do not group tasks +into phases, milestones, or sprints; ordering and dependencies are already +captured per task. Insert a checkpoint after every 2-3 tasks. -### Phase 2: Core Features -- [ ] Task 3: ... -- [ ] Task 4: ... +## Task 1: [Short descriptive title] -### Checkpoint: Phase 2 -- [ ] End-to-end flow works +**Description:** [What this task accomplishes.] -### Phase 3: Polish -- [ ] Task 5: ... -- [ ] Task 6: ... +**Rationale:** [Why this approach over the alternatives.] -### Checkpoint: Complete -- [ ] All acceptance criteria met -- [ ] Ready for review +**Acceptance criteria:** +- [ ] [Specific, testable condition] -## Testing Strategy -[How to verify: which test commands to run per module, what cases to cover, -manual verification steps, lint/format checks. Consult each module's core -memory for the exact commands.] +**Verification:** +- [ ] Relevant tests pass (module-specific command). + +**Dependencies:** None + +**Files likely touched:** +- `path/to/file` + +**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files] + +**Code sketch (optional):** [Short contract-level example, only if the shape +is non-obvious.] + +## Task 2: [Short descriptive title] + +[Same structure as Task 1.] + +## Task 3: [Short descriptive title] + +[Same structure as Task 1.] + +### Checkpoint: After Tasks 1-3 +- [ ] Relevant tests pass (module-specific command). +- [ ] The relevant build or compilation passes, if applicable. +- [ ] The core flow works end-to-end. +- [ ] Review with human before proceeding. + +## Task 4: [Short descriptive title] + +[Same structure as Task 1.] + +## Task 5: [Short descriptive title] + +[Same structure as Task 1.] + +## Verification & Testing +[How to verify each task and the whole plan: the project's real test, lint, +build, and run commands (extracted during Required Reading), coverage +expectations, and manual checks. Consult each module's core memory for the +exact commands.] ## Parallelization Opportunities - **Safe to parallelize:** Independent feature slices across separate - modules, tests for already-implemented features + modules, tests for already-implemented features, documentation - **Must be sequential:** Shared common schema changes, database migrations - **Needs coordination:** Features that share a contract (define the contract first, then parallelize) @@ -259,13 +347,31 @@ When the plan is purely analytical (e.g. a code review or feasibility study with no implementation), skip the **Approach** and **Task List** sections and lead with **Findings** instead, keeping the rest of the structure. +## Common Rationalizations + +| Rationalization | Reality | +|---|---| +| "I'll figure it out as I go" | That's how you end up with a tangled mess and rework. 10 minutes of planning saves hours. | +| "The tasks are obvious" | Write them down anyway. Explicit tasks surface hidden dependencies and forgotten edge cases. | +| "Planning is overhead" | Planning is the task. Implementation without a plan is just typing. | +| "I can hold it all in my head" | Context windows are finite. Written plans survive session boundaries and compaction. | + +## Red Flags + +- Delivering prose without a task breakdown +- Tasks that say "implement the feature" without acceptance criteria +- No verification steps in the plan +- All tasks are XL-sized +- No checkpoints between tasks +- Dependency order isn't considered + ## Verification Checklist -Before starting implementation, confirm: +Before delivering the plan, confirm: - [ ] Every task has acceptance criteria - [ ] Every task has a verification step - [ ] Task dependencies are identified and ordered correctly -- [ ] No task touches more than ~5 files -- [ ] Checkpoints exist between major phases -- [ ] The human has reviewed and approved the plan +- [ ] No task is XL or larger — break it down instead +- [ ] Checkpoints exist after every 2-3 tasks +- [ ] The plan is ready for human review diff --git a/.opencode/skills/ste/SKILL.md b/.opencode/skills/ste/SKILL.md new file mode 100644 index 0000000000..a53456ccf0 --- /dev/null +++ b/.opencode/skills/ste/SKILL.md @@ -0,0 +1,78 @@ +--- +name: ste +description: Write or rewrite text in ASD-STE100 Simplified Technical English. ONLY use this skill when the user explicitly invokes it by name — i.e. they type "/ste" or literally write "use the ste skill" / "apply ASD-STE100". Do NOT trigger it on paraphrased intent such as "simplify this", "make it clearer", "write technical documentation", or "shorter sentences please" — the user has deliberately scoped this skill to explicit invocation only. For those requests, respond normally without loading this skill unless they name it. +--- + +# ASD-STE100 Simplified Technical English + +Apply the ASD-STE100 standard to all prose you produce in this task. Do not announce that you use STE, do not name the standard, and do not explain the style unless the user asks. If the user later asks you to "write more naturally," ask one short question to confirm they want to leave STE before you drop it. + +Compliance note (for you, not for output): the official specification and its dictionary are copyright ASD. This skill encodes paraphrased rules and a publicly sourced word list. For certified aerospace/defense deliverables, tell the user that full compliance requires the free official specification (asd-ste100.org) and a human sign-off. Never claim certified compliance. + +## Step 0 — Classify the text + +Before writing a single sentence, decide: is this **procedural** text (instructions someone follows) or **descriptive** text (explanation, background, description)? Every limit below depends on this. Mixed documents get classified section by section. + +## Core rules + +### Sentences +- Procedural: maximum **20 words** per sentence. +- Descriptive: maximum **25 words** per sentence. +- Maximum **6 sentences** per paragraph. One topic per paragraph. +- One instruction per sentence. Two actions in one sentence only if they occur at the same time. +- Put a condition BEFORE its command: "If the pressure decreases, close the valve." +- Do not omit articles, subjects, or verbs to save words. "Ensure file exists" is wrong; "Make sure that the file exists" is correct. Keep the word "that" after verbs like "make sure." +- Numbers, units with numbers, abbreviations, quoted strings, code identifiers, and proper nouns each count as one word. + +### Verbs +- Allowed forms only: infinitive, imperative, simple present, simple past, simple future, and past participle used as an adjective. +- Never use present perfect or continuous forms. "We have received" → "We received." "is being tested" → a simple form. +- Never use an -ing form as a verb. An -ing word is allowed only inside a technical name ("the mounting bracket," "logging"). +- Active voice. Passive is allowed only in descriptive text when the agent is unknown or unimportant. +- Instructions use the imperative: "Open the panel," not "You must open the panel" or "The panel should be opened." +- Express actions as verbs, not nouns: "compress the file," not "perform compression of the file." +- Modals: use **can** (possibility), **will** (future), **must** (requirement). Do not use should, would, could, may, might. A hedge becomes a fact or a "can": "an explosion can occur." +- No phrasal verbs: "go down" → "decrease," "set up" → "install," "carry out" → "do." + +### Words +- One word, one meaning, one part of speech, used consistently. Never rotate synonyms: pick one name for a thing and repeat it. +- Before drafting, replace unapproved vocabulary. Read `references/word-substitutions.md` and apply it; it is the working dictionary for this skill. +- Domain-specific nouns (part names, tool names, product names, UI labels) and domain verbs (drill, ream, boot, compile) are your **technical nouns/verbs** — keep them as-is, use each consistently, and do not verb a noun or noun a verb. +- Noun clusters: maximum **3 words** ("overhead panel light" is the limit). Longer clusters get decomposed with prepositions or hyphenated on first use: "main-gear-door retraction-winch handle." +- American English spelling. +- No Latin abbreviations: "e.g." → "for example," "i.e." → "that is," delete "etc." + +### Punctuation +- No semicolons — write two sentences. +- Parentheses only for references, abbreviations, and item numbers. +- Hyphenate words that act as one unit; a hyphenated word counts as one word. +- No contractions. + +### Warnings, cautions, notes +- **WARNING** = risk of injury or death. **CAUTION** = risk of damage. **NOTE** = information only, never an instruction. +- Start a warning or caution with the command or condition, then give the risk: + "WARNING: Do not touch the terminal. The terminal has a dangerous voltage." +- Notes obey the 25-word descriptive limit. + +## Step 2 — Self-check pass + +After drafting, scan your text once for each of these and fix every hit before you respond: + +1. Any sentence over the 20/25-word limit for its type +2. Contractions, semicolons +3. "should," "would," "could," "may," "might" +4. "has been," "have been," "had been," "is being," "was being" +5. -ing words used as verbs +6. Missing articles (a/an/the/this) before nouns +7. Synonym rotation (the same object under two names) +8. Any word in the unapproved column of `references/word-substitutions.md` +9. Warnings that state the risk before the command + +## Reference files + +- `references/word-substitutions.md` — unapproved → approved word mappings and one-meaning rulings. Read it before drafting; it is short. +- `references/examples.md` — worked before/after rewrites (procedural, descriptive, warnings, common mistakes). Read it when rewriting existing text or when unsure how a rule applies. + +## What NOT to touch + +Code blocks, command strings, file paths, error messages, quoted UI text, and proper nouns stay exactly as written. STE applies to the prose around them. diff --git a/.opencode/skills/ste/references/examples.md b/.opencode/skills/ste/references/examples.md new file mode 100644 index 0000000000..b111db6229 --- /dev/null +++ b/.opencode/skills/ste/references/examples.md @@ -0,0 +1,67 @@ +# Worked before/after examples + +## Verb forms + +| Before | After | +|---|---| +| We have received the technical reports from HQ. | We received the technical reports from HQ. | +| This device has been being used at Boeing since 2005. | Boeing started to use this device in 2005. | +| The test is continued by the operator. | Continue the test. | +| The screws should be replaced. | Replace the screws. | +| The system is currently running diagnostics. | The system does diagnostic tests now. | + +## Vocabulary and phrasing + +| Before | After | +|---|---| +| Ensure file exists before running. | Make sure that the file exists before you run the command. | +| Rotate the cover until the jacks are accessible. | Turn the cover until you can get access to the jacks. | +| Extend the jack until the wheels are clear of the ground. | Extend the jack until the wheels do not touch the ground. | +| Make sure that these steps are followed. | Obey these steps. | +| Utilize approximately 3 liters of water. | Use about 3 liters of water. | +| Perform a compression of the log files prior to upload. | Compress the log files before you upload them. | + +## Noun clusters + +| Before | After | +|---|---| +| Main gear door retraction winch handle | Main-gear-door retraction-winch handle | +| Runway light connection resistance calibration | Calibration of the resistance of the runway light connection | +| Remove the engine transmission housing attachment bolts. | Remove the bolts that attach the transmission housing to the engine. | + +## Procedural rewrite (condition first, one instruction per sentence) + +Before: +> You should check that the reservoir has been filled with the correct fluid and, after attaching a clear tube to the bleed screw while ensuring its free end is submerged, the pedal can be pumped and held so that opening the screw releases trapped air. + +After: +> 1. Fill the reservoir with the correct fluid. +> 2. Attach a clear tube to the bleed screw. +> 3. Put the free end of the tube in a container of fluid. +> 4. Push the pedal three times. Hold the pedal down. +> 5. Open the bleed screw one half turn. Air and fluid flow into the tube. +> 6. Close the bleed screw. Release the pedal. +> 7. If air continues to come out, do steps 4 thru 6 again. + +## Warnings and cautions (command first, then risk) + +Before: +> Note that serious data loss may potentially occur if the --force flag is used against production. + +After: +> CAUTION: Do not use the --force flag on the production database. The flag deletes the rows that do not match the source. + +Before: +> Touching the terminal could result in electrocution. + +After: +> WARNING: Do not touch the terminal. The terminal has a dangerous voltage. + +## Common mistakes checklist + +- Dropped articles: "Insert pin in bracket" → "Insert the pin in the bracket." +- Synonym rotation: check/verify/confirm for the same action → one term, everywhere. +- Hedges: "you may want to," "it is recommended that" → an imperative or "must." +- Instruction buried in a NOTE: notes never instruct. Move the instruction to a numbered step. +- Semicolon joining two clauses → two sentences. +- "There are three bolts on the panel" → "The panel has three bolts." diff --git a/.opencode/skills/ste/references/word-substitutions.md b/.opencode/skills/ste/references/word-substitutions.md new file mode 100644 index 0000000000..8cda2511c1 --- /dev/null +++ b/.opencode/skills/ste/references/word-substitutions.md @@ -0,0 +1,68 @@ +# Word substitutions and one-meaning rulings + +Compiled from public secondary sources (STEMG/ASD public pages, TechScribe, Acrolinx, training materials). This is a working approximation, not the official ASD dictionary. When a word is not listed here and feels formal or Latin-derived, prefer the shortest common alternative. + +## Unapproved → approved + +| Do not use | Use instead | +|---|---| +| utilize, leverage, employ | use | +| commence, initiate, begin, originate | start | +| terminate, cease, conclude | stop, end | +| ensure, verify, confirm, validate, check | make sure (that), examine | +| perform, conduct, execute, carry out | do | +| facilitate, assist | help | +| obtain, acquire, procure | get | +| sufficient, adequate | enough | +| approximately | about | +| prior to | before | +| subsequent to, following (prep.) | after | +| adjacent to | near | +| accomplish | do | +| additional, supplementary | more | +| attempt | try | +| require, necessitate | need, must | +| mandatory | necessary | +| indicate, signify | show | +| observe (=watch) | look at, examine | +| rotate | turn | +| deactivate | turn off, set to off | +| activate, energize (unless technical verb) | turn on, start | +| toxic | poisonous | +| in order to | to | +| via, by means of | through, with | +| due to, owing to | because of | +| in the event of/that | if | +| accessible | (rewrite: "you can get access to") | +| remainder | rest | +| demonstrate | show | +| modify, alter | change | +| construct, fabricate, build | assemble, make | +| retain | keep | +| locate (=find) | find | +| depress (a button) | push, press | +| proceed | continue, go | + +## One meaning, one part of speech (canonical rulings) + +- **close** — verb only: to move to a position that stops flow, or to operate a circuit breaker. The adjective is unapproved → use **near** ("do not go near the propeller"). +- **test** — noun only: "do a test," never "test the system." +- **check** — do not use as a verb for verification → "make sure that" or "examine." +- **follow** — means only "come after." For rules and steps use **obey**: "Obey the safety instructions." +- **fall** — means only "move down by gravity." For quantities use **decrease**. Never the season. +- **oil** — noun only. "Oil the bearing" → "Put oil on the bearing" / "Lubricate the bearing." +- **right** — direction only, never "correct." +- **clear** — "without blockage." "Wheels are clear of the ground" → "wheels do not touch the ground." +- **help** — verb only; the noun is **aid** ("with the aid of a mirror"). +- **above / below** — physical position only. For quantities: **more than / less than**. +- **about** — two approved senses: "approximately" and "on the subject of." Use carefully. +- **turn** — the general verb for rotation; "turn on / turn off" for power state is standard. +- **level** — approved as noun and adjective (documented exception to the one-POS rule). + +## Frequent-offender function words + +- **should / would / could / may / might** — never. Requirement → **must**. Possibility → **can**. Future → **will**. +- **etc.** — delete, or write the full list. +- **e.g. / i.e.** — "for example" / "that is." +- **any / appropriate / applicable / relevant** as hedges — replace with the specific thing meant. +- **there is / there are** openers — rewrite with a real subject: "There are three bolts on the panel" → "The panel has three bolts." diff --git a/.opencode/skills/testing/SKILL.md b/.opencode/skills/testing/SKILL.md index 5ad5d04e6e..29ab7112f8 100644 --- a/.opencode/skills/testing/SKILL.md +++ b/.opencode/skills/testing/SKILL.md @@ -34,7 +34,8 @@ Before writing any test, read: 2. Module-specific testing memory for the affected module: - `mem:common/testing` — CLJC unit tests - `mem:frontend/testing` — CLJS unit tests, Playwright E2E - - `mem:backend/core` — JVM clojure.test conventions + - `mem:backend/testing` — JVM clojure.test conventions + - `mem:exporter/testing` — exporter unit tests ## Key Rules diff --git a/.opencode/skills/update-changelog/SKILL.md b/.opencode/skills/update-changelog/SKILL.md index d5c0ccf2af..d482959f94 100644 --- a/.opencode/skills/update-changelog/SKILL.md +++ b/.opencode/skills/update-changelog/SKILL.md @@ -212,6 +212,37 @@ superseded it: Replace the reference in the changelog entry with the correct merged PR number. +### 5b. Security advisory (GHSA) entries + +Security advisories fixed in a release are documented in the changelog even +though they are **neither milestone issues nor PRs**. The GHSA ID and its +description are supplied by the user or the release notes — they never come +from the milestone fetch in step 2. + +**Format** (matches the existing precedent in `CHANGES.md`, e.g. the +`create-font-variant` arbitrary file read advisory): + +```markdown +- Fix <user-facing description> (https://github.com/penpot/penpot/security/advisories/GHSA-XXXX-XXXX-XXXX) +``` + +Rules: +- Place the entry under `### :bug: Bugs fixed`, with **no issue or PR link** — + only the advisory URL. +- The advisory may be **draft/unpublished** at changelog time (the URL 404s + publicly). Do **not** web-fetch or verify the URL, and do **not** drop the + entry because of that. Rely on the GHSA ID provided by the user. +- Derive the description from the supplied advisory title, imperative mood and + user-facing (e.g. `Fix command injection in SVG exporter via legacy fill-color`). +- These entries are **invisible to the automation**: they are not returned by + `gh.py issues`, not matched by `--compare` (step 3), not part of the PR + cross-reference (step 10), and not scanned by the anomaly-report regexes + (step 11, which only match `issues/` and `pull/` links). Add them manually. +- During pre-flight checks (step 6a) apply only the **backport/duplicate** + check: if the same GHSA already appears in an earlier version section, remove + it from the current section. Their absence from milestone cross-references + is expected, not an anomaly. + ### 6. Read the current CHANGES.md Read the top of `CHANGES.md` to understand the existing format and find the @@ -400,6 +431,8 @@ if closed: - ✅ Every merged milestone PR is either in the changelog or excluded by label - ✅ PR and issue counts are internally consistent - ✅ No false-positive PR-to-issue associations +- ✅ Advisory (GHSA) entries are not milestone PRs — their absence from the + cross-reference is intentional (see step 5b) ## Version section template @@ -410,8 +443,12 @@ if closed: - <fix description> [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>)) - <fix description> (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>)) +- <fix description> (https://github.com/penpot/penpot/security/advisories/GHSA-XXXX-XXXX-XXXX) ``` +Advisory (GHSA) entries have no issue or PR link — just the advisory URL. See +step 5b. + ### 11. Generate anomaly report and save to CHANGES-ISSUES.md After all edits and cross-referencing are complete, generate a structured @@ -440,9 +477,15 @@ There are exactly two types: release, but the PR is being released elsewhere — the fix may not actually ship here. 2. **PR is in the milestone, but the issue it closes is in a different - milestone (or has no milestone).** The PR is being released here, but - the issue it fixes is being released in a different version (or never - tracked in a milestone) — the changelog pairing is misleading. + milestone.** The PR is being released here, but the issue it fixes is + being released in a different version — the changelog pairing is + misleading. + + **Exception — issue with no milestone is NOT an anomaly.** Milestones + are only required for issues tracked in the "Main" project. A milestone + PR that closes an issue with no milestone references an issue from + another (probably private) project; that is expected and the issue is + not part of this changelog. Do not report it. **Anything else is not an anomaly.** Other discrepancies (exclusion labels on in-changelog issues, missing valid issues, unmerged PR @@ -598,6 +641,10 @@ for pr_num in sorted(changelog_prs): if get_pr_milestone(pr_num) != MILESTONE: continue for issue_num in pr.get('closing_issues', []): issue_ms = get_issue_milestone(issue_num) + # No milestone = issue from another (probably private) project — + # milestones are only required for the "Main" project. Not an + # anomaly, and the issue never belongs in this changelog. + if issue_ms is None: continue if issue_ms != MILESTONE: anomalies_b.append({ 'pr': pr_num, @@ -620,7 +667,7 @@ with open(OUTPUT, 'w') as f: f.write('## Summary\n\n') f.write(f'- **Issue in {MILESTONE}, referenced PR in different milestone or no milestone:** {n_a}\n') - f.write(f'- **PR in {MILESTONE}, closing issue in different milestone or no milestone:** {n_b}\n') + f.write(f'- **PR in {MILESTONE}, closing issue in a different milestone:** {n_b}\n') f.write(f'- **Total anomalies:** {n_a + n_b}\n\n') # --- Anomalies section --- @@ -649,7 +696,7 @@ with open(OUTPUT, 'w') as f: f.write('\n') if n_b: - f.write(f'\n### PR in {MILESTONE}, closing issue in different milestone or no milestone\n\n') + f.write(f'\n### PR in {MILESTONE}, closing issue in a different milestone\n\n') by_pr = {} for b in anomalies_b: by_pr.setdefault(b['pr'], []).append(b) @@ -684,8 +731,11 @@ milestone mismatches between issues and their referenced PRs: 1. **Issue in milestone, referenced PR in different milestone or no milestone** — the changelog claims a fix here, but the PR is released elsewhere. -2. **PR in milestone, closing issue in different milestone or no milestone** — +2. **PR in milestone, closing issue in a different milestone** — the PR is released here, but the issue it fixes belongs to another version. + (An issue with *no* milestone belongs to another, probably private, + project — milestones are only required on the "Main" project — so it is + neither an anomaly nor a changelog candidate.) **Rule violations are not in the report** — they are workflow errors the LLM must fix directly in `CHANGES.md` during step 6a (pre-flight checks). @@ -732,6 +782,14 @@ self-contained and clickable in any Markdown viewer. Taiga description text or by searching GitHub PRs that reference the Taiga URL. Replace the Taiga reference with the GitHub issue link and add the PR reference if applicable. +- **Security advisory (GHSA) entries.** Advisories fixed in the release are + listed under `### :bug: Bugs fixed` with the advisory URL and **no issue or + PR link**, even though they are not in the milestone. The GHSA ID and + description come from the user — do **not** fetch or verify the URL, and do + not drop a draft (unpublished) advisory. Precedent: + `- Fix arbitrary file read security issue on create-font-variant rpc method + (https://github.com/penpot/penpot/security/advisories/GHSA-xp3f-g8rq-9px2)`. + See step 5b. - **Re-fetch before editing.** Milestones can change — always re-fetch issues before making edits, don't rely on cached data. - **Use `scripts/gh.py`.** Prefer the helper script over raw `gh api` calls for @@ -754,8 +812,11 @@ self-contained and clickable in any Markdown viewer. - **Anomaly = milestone mismatch only.** The report contains only milestone mismatches: (1) the issue is in this milestone but the referenced PR is in a different milestone (or unassigned), and (2) the PR is in this - milestone but the issue it closes is in a different milestone (or - unassigned). These are anomalies because the changelog pairing is + milestone but the issue it closes is in a different milestone. An + *unassigned* (milestone-less) issue closed by a milestone PR is **not** + an anomaly: milestones are required only for the "Main" project, so such + issues come from another (probably private) project and are not changelog + candidates. These anomalies are reported because the changelog pairing is *misleading* — the human needs to decide whether the milestone or the changelog is wrong. All other discrepancies (exclusion labels, missing valid issues, unmerged PR references, duplicates, stale milestone diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index 708d30fc5d..44dc8f3b75 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -5,7 +5,9 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m ## Focused memories - RPC, DB helpers, workers, cron: `mem:backend/rpc-db-worker-subtleties` -- HTTP sessions, config, storage, media, file data persistence: `mem:backend/http-storage-filedata-subtleties` +- Storage abstraction, logical buckets, object lifecycle, deduplication, access, and garbage collection: `mem:backend/storage`. +- HTTP sessions, config, media processing, and file data persistence: `mem:backend/http-storage-filedata-subtleties`. +- Embedded Ladybug graph experiment, projection, incremental sync, console, and risks: `mem:backend/graph-experiment` - Auth flows, permission model, teams, projects, invitations, comments, webhooks, audit: `mem:backend/auth-permissions-product-domains` - Services, task-queue/Pub-Sub topology constraints -> `mem:prod-infra/core`. @@ -101,10 +103,5 @@ misleading linter/compiler output. See `mem:scripts/paren-repair`. ## Testing -IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. JVM tests are invoked directly via `clojure -M:dev:test` — there is no pnpm wrapper. If you need to filter output, tee to a temp file first: `clojure -M:dev:test 2>&1 | tee /tmp/penpot-test-output.txt`. See `mem:testing` for execution discipline. - -* **Coverage:** If code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated. -* **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace. -* **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas. -* **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`. - +Backend test commands, coverage rules, and conventions: `mem:backend/testing`. +Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`. diff --git a/.serena/memories/backend/graph-experiment.md b/.serena/memories/backend/graph-experiment.md new file mode 100644 index 0000000000..a7d382b91f --- /dev/null +++ b/.serena/memories/backend/graph-experiment.md @@ -0,0 +1,632 @@ +# Graph Experiment + +## Scope + +- Purpose: project Penpot file data into an embedded Ladybug graph database. +- Purpose: keep the graph current with Penpot file changes. +- Purpose: expose a read-only graph console for backend debugging. +- This is an experiment, not a replacement for PostgreSQL file storage. +- The graph subsystem is off unless `:graph` is in the backend flags. +- The main Penpot frontend has no graph feature code for this subsystem. +- The graph console is a backend-served HTML template with JavaScript. + +## Memory Links + +- Read `mem:backend/core` for backend architecture, HTTP routes, DB rules, and test commands. +- Read `mem:backend/rpc-db-worker-subtleties` for RPC and message bus behavior. +- Read `mem:backend/http-storage-filedata-subtleties` for file data loading and realization. +- Read `mem:common/changes-architecture` for the change record vocabulary. +- Read `mem:frontend/routing-app-shell-subtleties` for the existing notification WebSocket. +- Read `mem:prod-infra/core` for Redis or Valkey message bus topology. + +## Branch Surface + +- The graph experiment adds about 6,336 lines and changes about 27 files. +- The graph implementation lives under `backend/src/app/graph/`. +- The graph console lives at `backend/resources/app/templates/graph-console.tmpl`. +- The existing debug page gains graph links in `backend/resources/app/templates/debug.tmpl`. +- The existing debug HTTP routes gain graph handlers in `backend/src/app/http/debug.clj`. +- The backend system passes the message bus to the debug route component in `backend/src/app/main.clj`. +- The backend adds Ladybug and Arrow dependencies in `backend/deps.edn`. +- The backend adds JVM options for Ladybug and Arrow native access. +- The common flag registry adds `:graph` in `common/src/app/common/flags.cljc`. +- The graph experiment adds `graph_sync_parity_test.clj` and `graph_binder_gate_test.clj`. + +## System Model + +### Storage layers + +- PostgreSQL remains the source of truth for Penpot files. +- The graph database stores a projection of one file. +- A persistent graph uses a `.lbug` path under `PENPOT_GRAPH_DIR`. +- The default graph directory is `/tmp/penpot-graph`. +- A debug session uses a Ladybug `:memory:` database. +- A debug session database lives inside the backend JVM process. +- A debug session does not survive a backend restart. +- A debug session does not store file data back to PostgreSQL. + +### Two graph update paths + +- Cold projection reads the complete file and rebuilds the graph. +- Incremental sync reads file change records and updates the open graph. +- Both paths must produce the same graph for the same file state. +- The parity test treats cold projection as the reference path. +- A reload discards the session graph and uses cold projection again. + +## Main Namespaces + +### `app.graph.ladybug` + +- Opens and closes Ladybug `Database` and `Connection` objects. +- Installs and loads the Ladybug JSON extension. +- Executes Cypher statements. +- Executes prepared statements. +- Binds scalar parameters. +- Formats UUID, string, integer, number, JSON, and timestamp values. +- Formats compound values such as arrays, maps, and structs. +- Converts Ladybug values back to Clojure values. +- Limits normal query results to 200 rows by default. +- Detects result truncation with `:truncated?`. +- Uses query timeout `0` by default. +- Query timeout `0` disables the timeout. +- Provides `validate-on-connection!` for parse, bind, and read-only checks. +- `exec-prepared-on-connection!` prepares every statement before the first execution. +- A prepare failure stops the batch before a mutation runs. + +### `app.graph.schema` + +- Provides the public schema facade. +- Exposes schema version `penpot-graph-slice-4`. +- Delegates node and relationship definitions to `app.graph.schema.nodes`. + +### `app.graph.schema.nodes` + +- Holds the single registry for graph node tables. +- Generates node DDL. +- Generates relationship DDL. +- Maps Penpot shape types to graph tables. +- Projects source attributes into graph attributes. +- Formats graph column values. +- Quotes reserved graph labels such as `Group` and `Boolean`. +- Defines container tables and shape tables. +- Defines `IsChildOf`, `IsInstanceOf`, `RefersTo`, and `FillsSwapSlot`. + +### `app.graph.schema.contract` + +- Records deliberate graph contract decisions. +- Renames graph columns such as `:revn` to `revision`. +- Drops attributes that do not belong in this graph slice. +- Records attributes that the graph does not project. +- Applies per-table dropped attributes. +- Defines type overrides for vectors, transforms, colors, maps, and JSON arrays. +- Maps selected map keys to the frontend JSON naming convention. +- `:background-blur` remains a declared unprojected attribute. + +### `app.graph.schema.projection` + +- Derives projected schemas from canonical Malli schemas. +- Builds the projected document schema. +- Builds projected shape schemas. +- Selects the schema for each shape type. + +### `app.graph.schema.types` + +- Maps Malli types to Ladybug types. +- Maps matrices to `DOUBLE[6]`. +- Maps points to `DOUBLE[2]`. +- Maps rectangles to `DOUBLE[4]`. +- Maps colors to `UINT32`. +- Maps collections to Ladybug arrays. +- Maps `:map-of` schemas to `MAP`. +- Maps closed scalar maps to `STRUCT`. +- Maps other complex values to `JSON`. + +### `app.graph.schema.values` + +- Coerces source values to graph column values. +- Writes fixed vectors with deterministic order. +- Packs colors into the graph color representation. +- Sorts set values when deterministic output is needed. + +### `app.graph.arrow` + +- Loads projection rows with Apache Arrow. +- Creates temporary staged node and relationship tables. +- Uses `COPY ... FROM (MATCH ...)` for bulk loading. +- Groups relationship loads by source and target table pair. +- Resolves relationship endpoints with joins. +- Does not use `createArrowRelTable` for UUID relationship endpoints. +- Keeps the Arrow `RootAllocator` alive until Ladybug releases staged buffers. +- Closes the allocator after the connection and database close sequence. + +### `app.graph.ingest` + +- Fetches a complete file with `bfc/get-file` and `:realize? true`. +- Rejects missing files. +- Rejects files without file data. +- Can run file data validation before projection. +- Creates the DDL. +- Loads nodes and edges through Arrow. +- Executes post-load transforms. +- Writes graph metadata last. +- Treats the final metadata write as the complete-build marker. +- Supports a persistent database path and an open connection. + +### `app.graph.projection.document` + +- Projects `Document`, `Page`, `Component`, and supported shape nodes. +- Skips the page root frame. +- Creates `IsChildOf` edges from shapes to parents. +- Creates page edges to the document. +- Creates component edges to the document. +- Stores page order in `Page.index` and edge `position`. +- Reverses the stored `:shapes` list for Penpot z-order. +- Adds `page-id` to every projected shape. +- Propagates an instance head `component-id` to descendants. +- Stops component inheritance at a non-Frame shape with its own component ID. +- Skips deleted components during cold projection. +- Logs unsupported shape types and missing shape records. + +### `app.graph.projection.transforms` + +- Runs after the base nodes and edges load. +- `link-component-instances` creates `IsInstanceOf` edges. +- A Frame needs `component-file` to qualify as an instance head. +- `link-shape-refs` creates `RefersTo` edges from `shape-ref`. +- Ladybug limits multi-label relationship `MERGE` statements. +- The transform emits one statement for each shape-table pair. +- `link-swap-slots` creates `FillsSwapSlot` edges. +- Swap slot IDs come from `swap-slot-<uuid>` entries in `touched`. +- The transform removes swap slot entries from `touched` after edge creation. +- The transform order matters because it reads and then changes `touched`. + +### `app.graph.meta` + +- Stores graph provenance in `GraphMeta`. +- Stores schema version, source revision, producer, and build time. +- The source revision identifies the file revision used for cold projection. + +### `app.graph.stats` and `app.graph.report` + +- `app.graph.stats` counts graph nodes and relationships from the live catalog. +- `app.graph.report` prints ingest information for REPL use. + +## Cold Projection Flow + +1. Get the file row and realized file data from PostgreSQL. +2. Read the file revision from the file row. +3. Build the node and edge projection. +4. Create all graph tables from the graph schema. +5. Load node rows with Arrow. +6. Load relationship rows with Arrow. +7. Run `CHECKPOINT;`. +8. Run the registered derived transforms. +9. Write `GraphMeta` as the final build step. +10. Return file ID, file revision, database path, projection stats, and transform stats. + +### Projection node groups + +- `Document` contains file-level attributes without the file data blob. +- `Document.options` receives file-level options from the data blob. +- `Page` contains page attributes without the page object map. +- `Component` contains component attributes without component object maps. +- Shape tables contain the supported shape attributes. +- The graph stores selected derived attributes such as `page-id`. + +### Projection relationship groups + +- Structural edges use `IsChildOf`. +- Page and component edges point to `Document`. +- Derived edges come from the post-load transform registry. + +## Incremental Sync + +### Change source + +- `app.rpc.commands.files-update` persists the file update first. +- The same command publishes a `:file-change` message to the file topic. +- The topic key is the file UUID. +- The message contains the file ID, profile ID, session ID, revision, version, and changes. +- Library changes also publish a team-topic message. +- The graph session only consumes the file-topic `:file-change` messages. + +### Session subscription + +- `app.graph.debug/start-sync-loop!` creates a channel with a dropping buffer of 64. +- The session subscribes the channel to the file UUID topic. +- The loop reads one message at a time. +- The loop ignores message types other than `:file-change`. +- The loop stops when the channel closes. +- `destroy-session!` closes the channel and purges its message bus subscription. + +### Session state + +- Sessions are stored in a global `defonce` atom. +- The map key is the string form of `profile-id`. +- One profile has one graph session. +- Loading another file first destroys the old session. +- A session stores the Ladybug database and connection. +- A session stores a shared lock for graph access. +- A session stores file metadata. +- A session stores the incremental sync index. +- A session stores the message bus channel. +- A session stores load time and profile ID. +- The session keeps projection statistics but drops full projection rows after index creation. + +### Sync index + +- `build-index` starts from the complete cold projection. +- The index stores the graph file ID and document ID. +- The index stores the current graph revision. +- The index stores page IDs, names, and positions. +- The index stores component IDs, names, and deleted state. +- The index stores shape table, parent, position, frame, page, and component context. +- The index stores child IDs by parent ID. +- The index supports later change application without another PostgreSQL file read. + +### Change application + +- `apply-changes!` processes the change list in source order. +- Each supported change returns a new index and a list of Cypher statements. +- Unsupported changes enter the `:skipped` result. +- Supported changes enter the `:applied` result. +- The function collects all statements before it executes them. +- The function appends a document revision statement when at least one change applies. +- The index revision advances only when at least one change applies. +- A larger incoming revision than the index revision creates a warning. +- A revision gap does not trigger catch-up. + +### Shape change rules + +- `:add-obj` reuses `projection.document/denormalized-shape`. +- `:add-obj` creates the shape node and its parent edge. +- `:mod-obj` applies supported `:set` operations to graph columns. +- `:mod-obj` keeps false and zero values as values. +- `:del-obj` deletes shapes in deep post-order. +- `:mov-objects` detaches shapes from the old parent. +- `:mov-objects` closes the old sibling position gap. +- `:mov-objects` inserts shapes at the new position. +- `:mov-objects` updates `parent_id` and `frame_id`. +- `:mov-objects` rewrites container `shapes` values. +- The parent columns and child lists must match a cold projection. + +### Page and component change rules + +- Page add creates a projected page node and a document edge. +- Page delete removes the page subtree. +- Page modification updates supported page attributes. +- Component add creates a component node and document edge. +- Component modification updates supported component attributes. +- Component delete uses a soft-delete state. +- Component restore removes the soft-delete state. +- Component purge removes the component node and document edge. +- Component sync paths need more parity coverage than the current tests provide. + +## Session Locking + +- The sync loop and HTTP handlers share one lock per session. +- The lock protects one Ladybug connection from concurrent access. +- Queries acquire the lock before binder validation and execution. +- Graph data export acquires the lock before catalog reads. +- Session export acquires the lock before `EXPORT DATABASE`. +- A long query blocks sync for the same session. +- A sync batch blocks queries for the same session. +- Ladybug connection thread safety is not assumed. + +## Graph Query Rules + +- The console accepts Cypher text. +- Blank query text raises a validation error. +- The query first passes Ladybug prepare and bind checks. +- The query must pass the engine read-only analysis. +- A mutating query is rejected. +- The graph console does not provide a write path. +- A session graph is rebuilt from the file by Reload. +- Normal query results have a 200-row limit. +- Query results use string values for the HTML console representation. +- JSON requests receive a Transit JSON response with the query and result. +- HTML requests receive the rendered console with the result. + +## Graph Data Export + +### G6 data + +- `/dbg/actions/graph-data` reads the live Ladybug database. +- It does not read the sync index for nodes and edges. +- It therefore shows database drift if a batch fails after index update. +- Node export covers all registered node tables. +- Relationship export reads the Ladybug relationship catalog. +- Relationship export includes source, target, relationship name, and position. +- Node and relationship export uses a 100,000-row limit. +- The response reports `truncated` when a limit cuts the result. +- The response reports buffer-manager memory usage. + +### `.lbug` export + +- `source=file` rebuilds the persistent graph from PostgreSQL file data. +- `source=file` runs a synchronous full ingest for each request. +- `source=session` exports the caller profile's live in-memory graph. +- Session export uses Ladybug `EXPORT DATABASE` to Parquet files. +- Session export creates a new `.lbug` database with `IMPORT DATABASE`. +- The temporary Parquet staging directory is deleted after import. +- The final session `.lbug` file remains in the system temporary directory. +- The HTTP response streams the database file to the caller. + +## HTTP Routes and Access + +- The graph routes live in `backend/src/app/http/debug.clj`. +- The graph route list is added only when `:graph` is enabled. +- `/dbg/graph` serves the graph console page. +- `/dbg/actions/graph-files` returns the profile file tree. +- `/dbg/actions/graph-load` loads a file into the profile session. +- `/dbg/actions/graph-unload` closes the profile session. +- `/dbg/actions/graph-reload` rebuilds the loaded file graph. +- `/dbg/actions/graph-query` runs a read-only Cypher query. +- `/dbg/actions/graph-sync-status` returns the sync state. +- `/dbg/actions/graph-data` returns nodes and edges for G6. +- `/dbg/actions/graph-export` streams a `.lbug` database. +- The `/dbg` session middleware remains active. +- The `/dbg` admin middleware remains active. +- A devenv host with a profile ID passes the debug authorization rule. +- Other hosts need a profile email in the configured admin set. +- `/dbg/actions/graph-files` lists reachable teams, projects, and files. +- The file tree query has a 500-file limit. +- The graph handlers resolve graph namespaces at call time. +- The backend requires `app.graph.debug` and `app.graph.ingest` when the flag is on. +- Ladybug native loading then fails during route initialization instead of first use. + +## Console Frontend + +### Page type + +- `graph-console.tmpl` is a backend resource template. +- It is not a Rumext component. +- It is not part of the main frontend route table. +- The page uses browser `fetch` calls and a browser WebSocket. +- The page loads G6 version `5.1.1` from jsDelivr. + +### File tree + +- The page fetches `/dbg/actions/graph-files`. +- The response contains team, project, and file groups. +- The page creates the tree with DOM APIs. +- A file click submits the graph load form. +- The page shows a message when no file exists. + +### Graph rendering + +- The page fetches `/dbg/actions/graph-data`. +- The page converts graph nodes and edges to G6 data. +- The page skips repaint when the node and edge signature does not change. +- The page marks added, removed, and changed graph entities. +- The page supports tree, dagre, circular, force, and combo layouts. +- The page supports collapsed container combos. +- The page has render guards at 4,000 nodes and 8,000 edges. +- The `?safe` query option bypasses the render guard. +- The page shows graph size by node count and relationship count. +- The page shows buffer-manager memory in MiB. +- The page reports a CDN failure when G6 is undefined. + +### Query result filtering + +- A query can return `filter_*` columns with node IDs. +- The HTML result table hides columns with the `filter_` prefix. +- The JSON result keeps the full result. +- The graph view uses the hidden IDs to select matching nodes. +- The graph view re-runs the query after graph refresh. +- This keeps the query filter aligned with the current graph. +- A user column named `filter_*` follows the same hiding rule. + +### Node inspector + +- A node click creates a query for that node. +- The inspector calls `/dbg/actions/graph-query` with JSON negotiation. +- The inspector displays the full projected row. +- The inspector uses table and ID values from the graph data. + +## WebSocket Data Flow + +1. The page opens `/ws/notifications` with a random `session-id` query value. +2. The page sends `:subscribe-file` with a Transit UUID value. +3. The server makes sure that the file exists and that the profile has read permission. +4. The server subscribes the connection to the file topic. +5. `files_update` publishes `:file-change` to the same topic. +6. The graph session consumes the message from its message bus subscription. +7. The WebSocket server sends the message to the browser connection. +8. The browser adds the change to the changelog. +9. The browser fetches sync status after 150 milliseconds. +10. The browser fetches graph data after a 400-millisecond debounce. +11. The browser repaints the G6 graph when the graph data changes. + +### WebSocket reconnect behavior + +- The page reconnects after three seconds when the socket closes. +- The page resubscribes to the file after the socket opens. +- The page refreshes sync status after reconnect. +- The page refreshes graph data after reconnect. +- Reconnect does not recover dropped message-bus changes. +- The page shows the sync error or skipped-change state when the status reports it. + +## Feature Flag and Runtime Dependencies + +- `:graph` is defined in `common/src/app/common/flags.cljc`. +- The flag is off by default. +- `com.ladybugdb/lbug` version `0.19.1` is a backend dependency. +- `org.apache.arrow/arrow-memory-netty` version `18.2.0` supports Arrow `RootAllocator`. +- The JVM uses `--enable-native-access=ALL-UNNAMED`. +- The JVM uses `--add-opens=java.base/java.nio=ALL-UNNAMED`. +- The JVM uses `--sun-misc-unsafe-memory-access=allow`. +- The JVM options appear in the development alias and backend launch scripts. +- A Ladybug version change needs new binder and parity tests. +- A JDK version change needs a startup test with the graph flag enabled. + +## Tests + +### `backend-tests.graph-sync-parity-test` + +- Uses two Ladybug `:memory:` databases. +- Does not use PostgreSQL or a live graph session. +- Projects initial file data into database A. +- Applies changes to database A through incremental sync. +- Applies the same changes to file data. +- Projects the changed file data into database B. +- Compares every node row and relationship row. +- Reports differences by table, row key, and column. +- Covers shape add, shape modification, shape deletion, movement, and page changes. +- Contains a test that injects a sync defect and expects a graph difference. +- Does not cover all component change variants. +- Does not cover every movement insertion mode. + +### `backend-tests.graph-binder-gate-test` + +- Creates the live graph DDL in a Ladybug `:memory:` database. +- Prepares each sync statement template without executing it. +- Detects parse errors and missing tables. +- Detects missing columns and bad label quoting. +- Reports the expected read-only classification. +- Covers reserved node labels across the node registry. +- Reports an error result for an invalid statement. + +### Test gaps + +- No automated HTTP handler tests cover graph routes. +- No automated session lifecycle tests cover load and unload. +- No automated WebSocket tests cover graph subscription. +- No automated export tests cover persistent and session sources. +- Component add, modify, delete, restore, and purge need parity tests. +- Page delete needs parity coverage. +- Movement with `:after-shape` needs parity coverage. +- Buffer overflow and revision gap behavior need tests. +- Partial batch failure and recovery need tests. +- Query timeout and long-query behavior need tests. + +## Known Risks and Limits + +### Dropped changes + +- The sync channel uses a dropping buffer of 64. +- A burst can discard file-change messages. +- The sync loop logs a revision gap when it sees a larger revision. +- The sync loop does not fetch missing rows from `file_change`. +- Reload is the only built-in recovery path. + +### Partial batch state + +- `apply-changes!` does not provide Ladybug transaction atomicity. +- A statement failure can leave a partly changed graph. +- The in-memory index can advance before the database state is complete. +- `/dbg/actions/graph-data` reads the database and exposes this drift. +- Reload rebuilds the graph from PostgreSQL file data. + +### Query resource use + +- The default session query timeout is zero. +- A costly query can hold the session lock for a long time. +- The same lock blocks incremental sync. +- The graph export also holds the same lock during catalog reads. +- The graph schema has a high memory floor. +- The console reports about 115 MiB for the wide slice before file data. + +### Session lifecycle + +- Sessions have no TTL. +- Sessions remain until unload, replacement, or process shutdown. +- Each session owns native Ladybug memory. +- Many profiles can create many native databases. +- A profile load replaces its previous session. +- Two browser tabs for one profile share one graph session. + +### Temporary files + +- Session export leaves the final `.lbug` file in the system temporary directory. +- Long-lived servers can accumulate exported session databases. +- The staging directory is deleted after import. + +### Browser dependency + +- The graph view depends on a runtime CDN request. +- A network restriction can remove the G6 view. +- Queries and session status still use backend endpoints without G6. + +### Data exposure + +- The graph console can list many files available to the profile. +- The console can load complete projected file data. +- The console can export a graph database. +- The console can inspect all projected node attributes. +- The console is safe only when the `/dbg` access boundary is correct. +- The graph flag must remain off for deployments that do not need this tool. + +### Contract drift + +- The graph schema is a deliberate slice of the Penpot file model. +- New source attributes do not enter the graph automatically in all cases. +- Dropped and unprojected attributes need an explicit contract decision. +- `applied_tokens` key mapping depends on the JSON naming function. +- `filter_*` is a frontend convention, not a graph schema guarantee. + +### Ladybug dialect coupling + +- Cypher strings contain Ladybug-specific syntax. +- Label quoting handles reserved labels explicitly. +- Relationship transforms depend on Ladybug relationship limits. +- Arrow loading depends on Ladybug `COPY FROM (MATCH ...)` behavior. +- A dependency upgrade needs schema, binder, Arrow, and parity checks. + +## REPL Helpers + +- `app.srepl.main` resolves graph functions only when a helper runs. +- `graph-smoke-test!` runs a basic Ladybug operation. +- `graph-query-test!` runs a graph query test. +- `ingest-file-to-graph!` projects a file into a graph database. +- These helpers use `requiring-resolve` to keep the graph dependency lazy. + +## Operational Invariants + +- PostgreSQL file data remains authoritative. +- Cold projection and incremental sync must produce equal graph state. +- The graph revision must identify the last applied file revision. +- The document revision must update when a sync batch applies. +- A missing or skipped change must remain visible in sync status. +- A graph query from the console must be read-only. +- A graph session must serialize connection access. +- Graph routes must remain behind the `:graph` flag and `/dbg` access control. +- The Arrow allocator must outlive all Ladybug operations that use its buffers. +- `GraphMeta` must be written after the full ingest and transforms finish. + +## Key Files + +- `backend/src/app/graph/ladybug.clj`: Ladybug API and query gates. +- `backend/src/app/graph/arrow.clj`: Arrow bulk load. +- `backend/src/app/graph/ingest.clj`: Complete file ingest. +- `backend/src/app/graph/debug.clj`: Session lifecycle, sync loop, query, and export. +- `backend/src/app/graph/sync.clj`: Incremental change application. +- `backend/src/app/graph/meta.clj`: Graph provenance. +- `backend/src/app/graph/stats.clj`: Graph counts. +- `backend/src/app/graph/report.clj`: REPL ingest report. +- `backend/src/app/graph/projection/document.clj`: Base document projection. +- `backend/src/app/graph/projection/transforms.clj`: Derived relationship transforms. +- `backend/src/app/graph/schema/nodes.clj`: Node and relationship registry. +- `backend/src/app/graph/schema/contract.clj`: Projection contract decisions. +- `backend/src/app/graph/schema/projection.clj`: Malli projection schemas. +- `backend/src/app/graph/schema/types.clj`: Malli-to-Ladybug type mapping. +- `backend/src/app/graph/schema/values.clj`: Value coercion. +- `backend/src/app/http/debug.clj`: Graph route registration and handlers. +- `backend/src/app/http/websocket.clj`: File WebSocket subscription handlers. +- `backend/src/app/rpc/commands/files_update.clj`: File-change publication. +- `backend/src/app/main.clj`: Integrant message bus wiring. +- `backend/resources/app/templates/graph-console.tmpl`: Graph console browser code. +- `backend/resources/app/templates/debug.tmpl`: Debug page graph links. +- `common/src/app/common/flags.cljc`: `:graph` feature flag. +- `backend/test/backend_tests/graph_sync_parity_test.clj`: Cold versus sync parity. +- `backend/test/backend_tests/graph_binder_gate_test.clj`: Cypher binder gate. + +## Development Commands + +- Run backend commands from the `backend/` directory. +- Run focused parity tests with `clojure -M:dev:test --focus backend-tests.graph-sync-parity-test`. +- Run focused binder tests with `clojure -M:dev:test --focus backend-tests.graph-binder-gate-test`. +- Run the backend test suite with `clojure -M:dev:test`. +- Examine Clojure formatting with `pnpm run check-fmt:clj`. +- Run backend Clojure lint with `pnpm run lint:clj`. +- Write test output to a file before reading or filtering it. diff --git a/.serena/memories/backend/http-storage-filedata-subtleties.md b/.serena/memories/backend/http-storage-filedata-subtleties.md index e9c0962371..188ece7277 100644 --- a/.serena/memories/backend/http-storage-filedata-subtleties.md +++ b/.serena/memories/backend/http-storage-filedata-subtleties.md @@ -14,10 +14,7 @@ ## Storage and media -- Storage has a fixed valid bucket set. Backends are `:fs` and `:s3`; default backend comes from deprecated `assets-storage-backend` only when present, otherwise `objects-storage-backend`, defaulting to `:fs`. -- `put-object!` creates the DB `storage_object` row before writing backend content. Backend writes happen only for newly created rows, so deduplication can skip object writes. -- Deduplication only applies when requested, when the content can provide a hash, and when bucket metadata is present. Reads exclude soft-deleted storage rows. -- `sto/resolve` can reuse the current DB connection via `::db/reuse-conn true`; preserve this in transaction-sensitive code. +- Storage abstraction, backend configuration, logical buckets, object lifecycle, deduplication, access rules, and garbage collection: `mem:backend/storage`. - SVG validation strips DOCTYPE and uses secure SAX parsing. Basic SVG info falls back to 100x100 dimensions when width/height/viewBox are missing. - Raster metadata is shell-derived with ImageMagick `identify`, verifies detected MIME against the supplied MIME, and swaps dimensions for EXIF orientations 6/8. - Remote image download requires 2xx status, `content-length`, a known MIME, and size under the configured maximum before writing the temp file; mismatched byte count is an internal error. @@ -28,4 +25,4 @@ - File data backends are `legacy-db`, `db`, and `storage`. The storage backend keeps encoded file data in storage bucket `file-data`; the DB row stores metadata with `storage-ref-id` and nil data. - `fdata/upsert!` touches any storage object referenced by incoming metadata before storing the new row/blob. - Pointer-map fragments are persisted separately as type `fragment`, and only modified pointer maps are written. -- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders. \ No newline at end of file +- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders. diff --git a/.serena/memories/backend/storage.md b/.serena/memories/backend/storage.md new file mode 100644 index 0000000000..101c77702a --- /dev/null +++ b/.serena/memories/backend/storage.md @@ -0,0 +1,119 @@ +# Backend Storage + +## Abstraction + +- `app.storage` stores binary objects. +- Each object has a `storage_object` database row. +- The row stores the UUID, size, backend, timestamps, and Transit metadata. +- The backend stores the binary content. +- Supported backends are `:fs` and `:s3`. +- FS uses one root directory and a UUID-derived path. +- S3 uses one configured bucket and an optional prefix. +- A Penpot bucket is metadata. It is not an S3 bucket or a filesystem directory. +- FS and S3 use the same UUID-derived object path. The bucket does not change the path. +- `PENPOT_OBJECTS_STORAGE_*` configures the current object backend. +- Deprecated asset-storage config keys remain supported for migration. +- Database rows keep the backend name. Keep the legacy `:assets-fs` and `:assets-s3` aliases. + +## Object Lifecycle + +- `put-object!` creates the database row before it writes backend content. +- Backend content is written only when the row is new. +- A failed backend write can leave an unreferenced database row. +- Callers often set `:touched-at` so garbage collection can remove such rows. +- `get-object` excludes rows with `deleted_at`. +- Existing object values can remain readable until physical deletion. +- `:expired-at` blocks reads after the expiration time. +- `del-object!` sets `deleted_at`. It does not remove backend content. +- `storage-gc-deleted` removes the database row and backend content after the deletion delay. +- `storage-gc-touched` finds references before it sets `deleted_at`. +- `objects-gc` removes deleted domain rows and touches their storage object IDs. +- Use `::db/reuse-conn true` with `sto/resolve` inside a database transaction. + +## Connection Reuse Details + +### `app.storage/resolve` patterns: + +**1. Pool mode (default)** - `(sto/resolve cfg)` +- Returns storage abstraction from config +- Uses whatever database pool is available +- **Safe to call outside transaction context** +- Used in: `rpc/commands/media.clj:363`, `rpc/commands/auth.clj:327`, `rpc/commands/profile.clj:362` + +**2. Connection reuse mode** - `(sto/resolve cfg ::db/reuse-conn true)` +- Internally calls `db/get-connection cfg` to obtain connectable +- Configures storage with the specific connection from config +- **Must be paired with transaction that owns this connection** +- Used in: `features/fdata.clj:100`, `rpc/commands/media.clj:425`, `rpc/commands/files_thumbnails.clj:307,319`, `binfile/v3.clj:722` + +**3. Explicit configuration** - `(sto/configure storage conn)` +- Sets `::db/conn` on storage map directly +- Asserts `db/conn? connection` (storage.clj:349) +- Used inside `db/tx-run!` blocks where `conn` is already available +- Used in: `tasks/file_gc.clj:256`, `rpc/commands/files_thumbnails.clj:347,371` + +### Key Warning (from function notes): + +The improved note in `import-storage-objects` and `handle-persistence` warns: +**Do not reuse the main database connection for storage operations within a transaction.** The storage upload process can fail mid-operation, leaving orphaned objects on the backend. If the outer transaction aborts, pending storage objects become unreconciliable because the storage subsystem registers its pending state in separate transactions. + +### Rule of Thumb for `sto/put-object!`: + +Since `put-object!` uses backend-specific operations (`impl/resolve-backend` + `impl/put-object`) and does not directly use `::db/conn` or `::db/pool`, **all usage of `put-object!` will never run inside a common transaction** (if configured at all). The storage backend operations are independent of the database transaction boundary. + +## Deduplication + +- Deduplication requires `::sto/deduplicate?`, a content hash, and bucket metadata. +- The lookup matches hash, bucket, backend, and `deleted_at IS NULL`. +- The lookup only considers rows with `status='valid'`; pending rows are invisible. +- A hit whose blob is missing is repaired in place: the same row/id is kept, + and `put-object!` rewrites the blob under that id. This heals all existing + references to the object. If the rewrite fails, the row is left live and + valid for a later retry. +- The lookup does not include file ID, profile ID, team ID, or organization ID. +- Objects can therefore share content across users and files within one bucket. +- Deleted objects are not reused. +- `tempfile` objects never use deduplication, even when the caller requests it. +- Use `sto/wrap-with-hash` when the caller already calculated the content hash. + +## Bucket Rules + +| Bucket | Content and references | Dedup | Direct `/assets/by-id` access | Cleanup | +| --- | --- | --- | --- | --- | +| `file-media-object` | Original file images and generated media thumbnails. References: `file_media_object.media_id` and `thumbnail_id`. | Yes | Public | Reference scan. | +| `team-font-variant` | Font variants in `team_font_variant`. References: `woff1_file_id`, `woff2_file_id`, `otf_file_id`, and `ttf_file_id`. | Yes | Public | Reference scan. | +| `file-object-thumbnail` | Frame and component thumbnails in `file_tagged_object_thumbnail.media_id`. | Yes | Public | Reference scan. | +| `file-thumbnail` | File grid thumbnails in `file_thumbnail.media_id`. | Yes | Authentication required | Reference scan. | +| `profile` | User and team profile photos. References: `profile.photo_id` and `team.photo_id`. | Yes | Authentication required | Reference scan. | +| `organization` | Organization logos uploaded by the Nitrate management API. | Yes | Public | No reference scan. A touched object is deleted. | +| `tempfile` | Export files, chunked-upload chunks, and temporary font downloads. | No | Authentication required | No reference scan. A touched object uses a two-hour deletion delay. | +| `file-data` | Encoded file data when `file-data-backend` is `storage`. Reference metadata has `storage-ref-id`, `file-id`, and the `file_data` row ID. | Yes | Authentication required | Reference scan. | +| `file-data-fragment` | Compatibility value for file-data fragments. The current backend has no dedicated producer for this bucket. | No current write semantics | Public | No touched-object collector case. | +| `file-change` | Compatibility value for file changes. Current snapshots store data in `file_data`, not this bucket. | No current write semantics | Authentication required | No touched-object collector case. | + +- The valid bucket set lives in `app.storage/valid-buckets`. +- `file-media-object` is the default bucket for old rows without bucket metadata. +- Do not assign a new bucket without adding its access and cleanup behavior. +- The touched-object collector raises an internal error for an unknown bucket. +- It supports `file-media-object`, `team-font-variant`, `file-object-thumbnail`, `file-thumbnail`, `profile`, `file-data`, `tempfile`, and `organization`. +- It does not support `file-data-fragment` or `file-change`. + +## Access Rules + +- `app.http.assets` decides direct object authentication from the bucket. +- Public buckets are `file-media-object`, `file-object-thumbnail`, `team-font-variant`, `file-data-fragment`, and `organization`. +- Other valid buckets require a session or access-token profile ID. +- File-media routes also require file read permission. +- Non-public direct responses set `content-disposition: attachment`. +- FS responses use `x-accel-redirect` for the configured asset path. +- S3 responses use a presigned URL and an HTTP redirect. + +## File Data + +- `file-data-backend` accepts `legacy-db`, `db`, or `storage`. +- `legacy-db` stores main data in `file.data` and snapshots in `file_change.data`. +- `db` stores encoded data in `file_data.data`. +- `storage` stores encoded data in storage subsystem with `file-data` bucket and keeps `data` nil in `file_data` table. +- The `file_data.metadata.storage-ref-id` value points to the storage object. +- `fdata/upsert!` touches a storage object from incoming metadata before it stores the new row. +- File snapshots use `file_data` for snapshot data and `file_change` for snapshot metadata. diff --git a/.serena/memories/backend/testing.md b/.serena/memories/backend/testing.md new file mode 100644 index 0000000000..66d6f8a246 --- /dev/null +++ b/.serena/memories/backend/testing.md @@ -0,0 +1,11 @@ +# Backend Testing + +JVM `clojure.test` (kaocha runner) under `backend/test/backend_tests/`. + +- READ `mem:testing` FIRST — it defines the execution discipline (no piping, tee to file, preferred commands) that applies to all JVM test runs. +- All CLI commands must be executed from the `backend/` subdirectory. +- Tests are invoked directly via `clojure -M:dev:test` (kaocha) — there is no pnpm wrapper. Kaocha auto-discovers test namespaces, so no runner registration is needed. +- Coverage: if code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated. +- Isolated run: `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace, or `clojure -M:dev:test --focus backend-tests.my-ns-test/my-test-var` for a specific test var. +- Regression run: `clojure -M:dev:test` to ensure no regressions in related functional areas. +- If you need to filter output, tee to a temp file first: `clojure -M:dev:test 2>&1 | tee /tmp/penpot-test-output.txt`. \ No newline at end of file diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index b032c97180..117e8ff464 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -39,6 +39,7 @@ This is a monorepo. Principles that apply to one module do *not* generally apply - `plugins/`: TypeScript plugin runtime/examples and Plugin API types; core conventions: `mem:plugins/core`. - `library/`: design library workflows; core conventions: `mem:library/core`. - `docs/`: documentation site; core workflow and conventions: `mem:docs/core`. +- `media-processor/`: TypeScript/Node.js HTTP service for image (sharp) and font (FontForge) processing; core conventions: `mem:media-processor/core`. The memory is structured in a way that you can get the critical information about the module. You can read it from `mem:<MODULE>/core` diff --git a/.serena/memories/devenv/core.md b/.serena/memories/devenv/core.md index 0651f872fe..db46542c99 100644 --- a/.serena/memories/devenv/core.md +++ b/.serena/memories/devenv/core.md @@ -6,6 +6,7 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par - `penpotdev-infra`: shared `postgres`, `minio`, `minio-setup`, `mailer`, `ldap`. File: `docker-compose.infra.yml`. - `penpotdev-wsN` (N=0,1,…): per-instance `main` + `redis` (Valkey). File: `docker-compose.main.yml`. ws0 (a.k.a. `main`) binds `$PWD`; ws1+ bind clones at `${PENPOT_WORKSPACES_DIR}/wsN/` (default `~/.penpot/penpot_workspaces/`), maintained by the developer. +- Optional overlay `docker-compose.opencode.yml`: added by `instance-compose` as an extra `-f` only when `PENPOT_OPENCODE_CONFIG_DIR` is set (i.e. `run-devenv --opencode-config-dir DIR` ran in this process). Bind-mounts the host dir at `/home/penpot/.config/opencode` (`:z`). Flag-only, per-call; not read from ambient env. Parser `parse-opencode-config-dir` absolutizes (`~`, realpath) because compose resolves relative bind sources against the compose file's dir. Only instances brought up with the flag get the mount. - All projects join external network `penpot_shared`. Created idempotently by `ensure-devenv-network`, never removed by lifecycle commands. ## Source-of-truth files @@ -65,7 +66,7 @@ No `--delete` on the working-tree pass: gitignored caches in the workspace survi ## CLI surface -- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). +- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX] [--opencode-config-dir DIR]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). `--opencode-config-dir DIR` bind-mounts DIR at `~/.config/opencode` in-container via the optional overlay above; mount applies at container creation, so changing it requires stop + re-run. - `stop-devenv [--ws main|0|wsN|N] [--all]`: stop instances. Flags mutually exclusive. `--ws N` stops just that workspace. `--ws 0` or no flag stops ws0; shared infra shuts down only if no other instances remain. `--all` stops every ws highest-first then ws0, then infra. - `run-devenv`: legacy alias, ws0 non-agentic attached. - `attach-devenv [--ws main|0|wsN|N]`: pure attach. Fails fast if instance/session missing. diff --git a/.serena/memories/exporter/core.md b/.serena/memories/exporter/core.md index 3bcf784f49..9b7078045b 100644 --- a/.serena/memories/exporter/core.md +++ b/.serena/memories/exporter/core.md @@ -5,9 +5,10 @@ ## Layout and commands - Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`. -- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`. +- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; test bundle `pnpm run build:test`; tests `pnpm run test` or `pnpm run test:quiet`; lint `pnpm run lint:clj`; format check/fix `pnpm run check-fmt:clj` / `pnpm run fmt:clj`. - Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`. - Cross-cutting testing principles and anti-patterns: `mem:testing`. +- Exporter test conventions and CI: `mem:exporter/testing`. ## HTTP and browser pool @@ -31,4 +32,4 @@ - WebP is produced by taking a PNG screenshot and converting it with ImageMagick. - SVG export rasterizes text foreignObjects to PNG, converts through PPM/color masks/potrace, and reassembles SVG paths. It also replaces non-breaking spaces for SVG compatibility and drops empty defs/paths. - PDF export injects `@page` sizing through raw browser `evaluate` JavaScript; that code cannot rely on CLJS runtime helpers. -- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter <management-key>` and Bearer auth. \ No newline at end of file +- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter <management-key>` and Bearer auth. diff --git a/.serena/memories/exporter/testing.md b/.serena/memories/exporter/testing.md new file mode 100644 index 0000000000..189c1e852c --- /dev/null +++ b/.serena/memories/exporter/testing.md @@ -0,0 +1,16 @@ +# Exporter Testing + +- READ `mem:testing` first. +- Tests use `cljs.test` and live under `exporter/test/exporter_tests/`. +- Register every test namespace in `exporter-tests.runner`. +- From `exporter/`: `pnpm run build:test` builds the Node test bundle without running tests. +- From `exporter/`: `pnpm run test` builds and runs tests with full output. +- From `exporter/`: `pnpm run test:quiet` builds and runs tests with reduced build output. +- After `build:test`, reuse the compiled bundle with `node target/tests/test.js`. +- For iterative focused runs, build once and reuse the compiled bundle. +- Focus a test namespace with `node target/tests/test.js --focus exporter-tests.renderer-svg-test`. +- Focus a test var with `node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element`. +- Set app log level by appending `--log-level warn` (or `trace|debug|info|warn|error`). +- `test:quiet` accepts forwarded options but rebuilds the bundle; prefer the direct runner after `build:test` for focused runs. +- From `exporter/`: `pnpm run check-fmt:clj` checks ClojureScript formatting. +- From `exporter/`: `pnpm run lint:clj` runs ClojureScript linting. diff --git a/.serena/memories/media-processor/core.md b/.serena/memories/media-processor/core.md new file mode 100644 index 0000000000..209d4b1a37 --- /dev/null +++ b/.serena/memories/media-processor/core.md @@ -0,0 +1,100 @@ +# Media Processor + +Stateless HTTP service for Penpot image and font processing. Handles image info extraction, thumbnail generation (sharp), and font conversion (FontForge, woff-tools). + +## Tech Stack + +- Language: TypeScript +- Runtime: Node.js +- Framework: Express +- Image processing: sharp (libvips) +- Font processing: FontForge (TTF/OTF), sfnt2woff, woff2_decompress +- Upload handling: multer (hybrid storage: memory for small, disk for large) +- Logging: pino (with optional Loki transport) +- Config validation: Zod +- Testing: Vitest +- Package Manager: pnpm + +## Project Structure + +``` +media-processor/ +├── src/ +│ ├── index.ts # Express app setup, routes, middleware +│ ├── config.ts # Zod-validated env config, HKDF key derivation +│ ├── types.ts # TypeScript type definitions +│ ├── upload.ts # Multer configuration, getFileBuffer helper +│ ├── upload-storage.ts # Hybrid storage engine (memory < threshold, disk >= threshold) +│ ├── logger.ts # Pino logger setup +│ ├── middleware/ +│ │ ├── auth.ts # Timing-safe shared key authentication +│ │ ├── error-handler.ts # ProcessingError class, centralized error handling +│ │ └── timeout.ts # Request timeout middleware +│ ├── routes/ +│ │ ├── health.ts # GET /api/health +│ │ ├── image.ts # POST /api/image/info, /api/image/thumbnail +│ │ └── font.ts # POST /api/font/convert +│ └── services/ +│ ├── image.ts # sharp-based image info/thumbnail generation +│ ├── font.ts # FontForge/woff-tools font conversion +│ └── errors.ts # throwValidation, throwRestriction, throwProcessing +├── test/ # Vitest test files +├── vitest.config.ts # Test configuration +├── tsconfig.json # TypeScript configuration +├── esbuild.config.mjs # Build configuration +└── package.json # Dependencies and scripts +``` + +## Key Conventions + +### Auth +- Requests authenticated via `x-shared-key` header using timing-safe comparison +- When no key configured, all requests rejected with 403 +- Key derived from `PENPOT_SECRET_KEY` via HKDF (blake2b512) or set directly via `PENPOT_MEDIA_PROCESSOR_SHARED_KEY` + +### Resource Limits +- Image: max pixels, max width/height enforced before processing +- Font: prlimit wraps FontForge processes with memory (AS) and CPU time limits +- Concurrency: p-queue limits concurrent requests (default 10) +- Upload: hybrid storage — memory for files < 10MB, disk for larger; configurable via `PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD` +- Max file size: configurable (default 350MB) + +### Error Handling +- `throwValidation(code, hint)` — 400 errors for invalid input +- `throwRestriction(code, hint)` — 413 errors for resource limits exceeded +- `throwProcessing(code, hint)` — 503 errors for processing failures (e.g., resource limit kills) + +### Image Processing +- EXIF orientation applied before dimension validation and thumbnail generation +- sharp caching disabled to prevent unbounded memory growth +- `withoutEnlargement: true` prevents upscaling small images + +### Font Conversion +- Supported formats: TTF, OTF, WOFF, WOFF2 +- SFNT type detected via magic bytes (0x4f54544f = OTF, 0x00010000 = TTF) +- Temp files cleaned up in finally blocks (best-effort) + +## Commands + +All commands run from `media-processor/` directory: + +- `pnpm run test` — Run Vitest test suite +- `pnpm run types:check` — TypeScript type checking (tsc --noEmit) +- `pnpm run fmt` — Format code with Prettier +- `pnpm run fmt:check` — Check formatting without modifying +- `pnpm run build` — Build for production (esbuild) +- `pnpm run start:dev` — Start development server (tsx) + +## Docker + +- Exposed port: 6065 (configurable via `PENPOT_MEDIA_PROCESSOR_PORT`) +- Must be deployed on internal Docker network only (not public-facing) +- Backend communicates via `PENPOT_MEDIA_PROCESSING_SERVICE_URI` + +## Testing Principles + +Cross-cutting testing principles and anti-patterns: `mem:testing`. + +- Run `pnpm run test` after changes +- Run `pnpm run types:check` after TypeScript changes +- Run `pnpm run fmt:check` before commits diff --git a/.serena/memories/prod-infra/core.md b/.serena/memories/prod-infra/core.md index e86eb69a32..1ec5af0308 100644 --- a/.serena/memories/prod-infra/core.md +++ b/.serena/memories/prod-infra/core.md @@ -6,7 +6,7 @@ Backend (`app.config`, `PENPOT_*` env vars) is parameterized; deployments choose - **PostgreSQL**: durable store. Profiles, teams, files, sessions, audit, `storage_object` metadata, the `task` queue, `scheduled_task` cron registry, migrations. File-data also lives here when the file-data backend is `legacy-db`/`db`. One shared DB across all backends. - **Redis (Valkey-compatible)**: per-backend message bus and cache. Concrete uses: msgbus Pub/Sub for collaborative-editing broadcasts and team/profile-org notifications fired by RPC handlers (`app.rpc.notifications`, `files_update`, `teams`, `websocket`); file-summary cache gated by `enable-redis-cache`; rate-limit counters; and the dispatcher→runner work hand-off list `penpot.worker.queue:<tenant>:<queue>`. `PENPOT_REDIS_URI`. -- **Object storage**: backends `:s3` and `:fs`. S3 in prod; devenv uses MinIO. Holds uploaded media, file-data when the file-data backend is `storage`, exports. Backend-side details (resolve, dedup, bucket set, file-data backends): `mem:backend/http-storage-filedata-subtleties`. +- **Object storage**: backends `:s3` and `:fs`. S3 in prod; devenv uses MinIO. Holds uploaded media, file-data when the file-data backend is `storage`, exports. Backend-side details (resolve, dedup, bucket set, object lifecycle, and file-data backends): `mem:backend/storage`. - **SMTP mailer**: invitations, password resets, email verification (sent via the `:sendmail` worker task). - **LDAP** (optional auth provider): helpers in `app.auth.*`, gated by `enable-login-with-ldap`. @@ -30,4 +30,4 @@ Penpot in production lives with both: horizontal-scale deployments accept "exact ## See also - Devenv composition and the ws0-only worker placement: `mem:devenv/core`. -- Storage backend resolution, dedup, file-data lifecycle: `mem:backend/http-storage-filedata-subtleties`. +- Storage backend resolution, dedup, bucket behavior, object lifecycle, and file-data lifecycle: `mem:backend/storage`. diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md index f57856a17d..dadbae5c2d 100644 --- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -17,9 +17,32 @@ ## Tile/render behavior +- Raster `Fill::Image`: skip `save_layer` unless the shape has an image filter; plain + Rect/Frame (no corners) also skip the container clip (`draw_image_fill` in fills.rs). +- `can_render_directly` paints onto Current (no Fills/Strokes blit) for plain geometry and + for stroke-free text (SrcOver, no blur/shadows). Multi-style text is fine: span styles + live in Paragraph `TextStyle`s. Text skips the `nested_fills` guard (fills are on spans). + `draw_text` only `save_layer`s when stroke-group opacity is set; plain fill paint is direct. +- Plain text fill paint reuses `TextContent.layout` paragraphs when + `has_usable_paint_layout` (paragraphs present + version match; during + interactive transforms rotation/move skips width check via + `modifier_changes_text_layout`, resize falls back to `layout_width` vs + `get_width(selrect.width())`), via `text::try_paint_from_layout_cache`. + The walker computes `text_layout_cache_rotation_only` from `tree` and + passes it into `render_shape`; stroke/shadow paths pass `false`. +- `TextContentLayout` paragraphs are `Rc`-shared on `Clone` so modifier clones + (rotate/pan) keep the paint cache; `needs_update` is paragraphs-empty only. + Decorations are skipped when no span requests underline/strike. +- Zoom settle: visible tiles present via `FrameType::ViewportReady` before interest-ring + work; crop-cache rebuild is deferred to the later `Full` so the soft→sharp snap is + compose+present only. - Interactive transforms are distinct from viewport fast mode. `set_modifiers_start` enables fast mode and interactive transform; interactive transform still flushes each animation frame. - During interactive transform, modifier tile invalidation is deferred to `render()` once per rAF. Outside interactive transform, `set_modifiers` rebuilds modifier tiles immediately. - `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render. - Plain viewport fast mode (`options.is_viewport_interaction()`) renders from cache and does not flush target output inside `process_animation_frame`; interactive transforms do flush. -- Zoom changes rebuild the tile index while preserving cached tile textures. Avoid replacing that path with shallow rebuilds if blur/shadow cache preservation matters. -- Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling. \ No newline at end of file +- Zoom settle wipes the tile texture cache in `set_view_end`. Mid-zoom overlays + key tiles by scale; shape edits must `invalidate_cached_tiles_intersecting` + the old∪new extrect so those overlays do not keep pre-edit pixels. +- Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling. +- Frames with a fill may use `render_frame_container_drop_shadow` (direct rrect + + blur saveLayer on `DropShadows`) when `uses_direct_container_drop_shadow` is true. diff --git a/.serena/memories/scripts/gh.md b/.serena/memories/scripts/gh.md index ed6adfa8f1..58f73e997d 100644 --- a/.serena/memories/scripts/gh.md +++ b/.serena/memories/scripts/gh.md @@ -9,6 +9,7 @@ repository via GraphQL and REST APIs through the authenticated `gh` CLI. - Finding issues with no milestone. - Fetching PR details by number or by milestone. - Comparing milestone issues against CHANGES.md to find missing entries. +- Listing or inspecting GitHub Security Advisories (GHSA). ## Prerequisites @@ -72,6 +73,30 @@ python3 scripts/gh.py prs --milestone "2.16.0" --state all **Output**: JSON array to stdout; progress to stderr. +### `advisories` + +List or inspect GitHub Security Advisories for the repository. + +```bash +# List all advisories (summary view) +python3 scripts/gh.py advisories + +# Filter by severity +python3 scripts/gh.py advisories --severity critical + +# Filter by state +python3 scripts/gh.py advisories --state triage + +# Get full detail for a single advisory +python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7 +``` + +**Summary output fields**: ghsa_id, cve_id, severity, cvss_score, state, summary, cwes, published_at, closed_at, url. + +**Detail output** (single advisory) adds: description, vulnerabilities (package, version ranges), credits, timestamps. + +**Output**: JSON to stdout; progress to stderr. + ## Key principles - All output is JSON — pipe into `jq` or other tools for further processing. diff --git a/.serena/memories/testing.md b/.serena/memories/testing.md index 295da86212..ce301ff5bb 100644 --- a/.serena/memories/testing.md +++ b/.serena/memories/testing.md @@ -13,7 +13,7 @@ and helpers, consult: builders, production-path change helpers - `mem:frontend/testing` — CLJS unit tests, Playwright E2E integration tests, live browser verification via nREPL -- Backend — JVM `clojure.test` under `backend/test/`; see `mem:backend/core` +- `mem:backend/testing` — JVM `clojure.test` under `backend/test/` ## When to Use diff --git a/.serena/memories/workflow/creating-commits.md b/.serena/memories/workflow/creating-commits.md index d37d4672a5..2fc766d4ad 100644 --- a/.serena/memories/workflow/creating-commits.md +++ b/.serena/memories/workflow/creating-commits.md @@ -14,6 +14,8 @@ automatically pull the identity from the local git config `user.name` and `user. :emoji: Subject line (imperative, capitalized, no period, <=70 chars) Body explaining what changed and why. +Wrap lines at 72 characters — git log and tooling +render long lines poorly. Keep each line concise. AI-assisted-by: model-name ``` @@ -25,3 +27,7 @@ AI-assisted-by: model-name ## Commit Type Emojis `:bug:` bug fix · `:sparkles:` enhancement · `:tada:` new feature · `:recycle:` refactor · `:lipstick:` cosmetic · `:ambulance:` critical fix · `:books:` docs · `:construction:` WIP · `:boom:` breaking · `:wrench:` config · `:zap:` perf · `:whale:` docker · `:paperclip:` other · `:arrow_up:` dep upgrade · `:arrow_down:` dep downgrade · `:fire:` removal · `:globe_with_meridians:` translations · `:rocket:` epic/highlight + +## Referencing Issues + +Use `Closes #NNNN` (not `Fixes #NNNN`) to link a commit to a GitHub issue. diff --git a/.serena/memories/workflow/creating-prs.md b/.serena/memories/workflow/creating-prs.md index a92c199e22..fa94a9afe0 100644 --- a/.serena/memories/workflow/creating-prs.md +++ b/.serena/memories/workflow/creating-prs.md @@ -1,6 +1,12 @@ # Creating Pull Requests -PR only on explicit request. Branch: issue/feature-specific; fallback `<type>/<short-description>` (`fix/...`, `feat/...`, `refactor/...`, `docs/...`, `chore/...`, `perf/...`). +PR only on explicit request. + +## Branch Naming + +- Primary: `issue-NNNN` — one branch per GitHub issue (e.g. `issue-11525`). +- No issue: free-form descriptive name, dash-separated, no slashes (e.g. `fix-ellipse-icon-typo`, `feat-auto-link-libraries`). +- If the user already created the branch, use it as-is — never rename. ## Target Branch diff --git a/AGENTS.md b/AGENTS.md index ac4da5c663..06507f18d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,36 @@ Skipping this step is the #1 cause of incorrect or incomplete work. --- +## Auto-triggers + +- **Security advisory URL pasted** — When the user pastes a URL matching + `github.com/penpot/penpot/security/advisories/GHSA-*`, extract the GHSA ID + from the URL and run `python3 scripts/gh.py advisories <GHSA-ID>` to fetch + full advisory details before proceeding. +- **Issue or PR mentioned** — When the user mentions a penpot/penpot issue or + PR (URL like `github.com/penpot/penpot/issues/<n>` / `.../pull/<n>`, or a + bare `#<n>` when context clearly refers to this repo), fetch details via CLI + instead of WebFetch: + - Issue → `gh issue view <n> --repo penpot/penpot` (add `--comments` when + discussion context matters). + - Single PR → `gh pr view <n> --repo penpot/penpot`. + - Multiple PRs (list, file, or milestone) → `python3 scripts/gh.py prs ...`. + Do this before proceeding. Only use WebFetch if the CLI fails. + +## Writing Rules + +Writing rules, from Orwell, 1946. These govern prose: docs, PR text, messages. Never touch code or technical terms; swap in everyday words only where precision survives. + +1. Never use a metaphor, simile or other figure of speech which you are used to seeing in print. +2. Never use a long word where a short one will do. +3. If it is possible to cut a word out, always cut it out. +4. Never use the passive where you can use the active. +5. Never use a foreign phrase, a scientific word or a jargon word if you can think of an everyday English equivalent. +6. Break any of these rules sooner than say anything outright barbarous. +Review every prose output against these rules before delivering. + +--- + # Memory system Memories are the **primary project guidance** — not docs or readme files. @@ -113,4 +143,5 @@ precision while maintaining a strong focus on maintainability and performance. - `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines. - `scripts/check-fmt-clj` — Check Clojure formatting without modifying files. - `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `scripts/ci --help`. +- `scripts/gh.py` — Multi-purpose GitHub CLI helper. Subcommands: `issues` (list issues in a milestone), `prs` (fetch PR details), `advisories` (list/inspect security advisories). See `python3 scripts/gh.py --help`. diff --git a/CHANGES.md b/CHANGES.md index bcfd77b8bc..1618253d1b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,25 @@ # CHANGELOG +## 2.19.0 (Unreleased) + +### :bug: Bugs fixed + +- Fix copying text from Penpot to the clipboard not working on MS Windows [#11303](https://github.com/penpot/penpot/issues/11303) (PR: [#11305](https://github.com/penpot/penpot/pull/11305)) +- Fix performance issue with WebGL render [#11240](https://github.com/penpot/penpot/issues/11240) (PR: [#11259](https://github.com/penpot/penpot/pull/11259)) +- Fix comment bubbles rendering on top of workspace dropdown menus [#10283](https://github.com/penpot/penpot/issues/10283) (PR: [#11201](https://github.com/penpot/penpot/pull/11201)) +- Fix inconsistent Mixed label in blur options and numeric inputs across 24 locales (by @filipsajdak) [#11148](https://github.com/penpot/penpot/issues/11148) (PR: [#11151](https://github.com/penpot/penpot/pull/11151)) +- Fix overlay shifting left when shown with top-center alignment in viewer prototype (by @filipsajdak) [#9048](https://github.com/penpot/penpot/issues/9048) (PR: [#10454](https://github.com/penpot/penpot/pull/10454)) +- Fix internal error when clicking the Copy button on the Access Token page (by @0xTHAC0) [#8496](https://github.com/penpot/penpot/issues/8496) (PR: [#11156](https://github.com/penpot/penpot/pull/11156)) +- Fix `disable-registration` flag not preventing non-users from creating accounts in the share prototypes page (by @0xTHAC0) [#5164](https://github.com/penpot/penpot/issues/5164) (PR: [#11199](https://github.com/penpot/penpot/pull/11199)) + +### :sparkles: New features & Enhancements + +- Make backend storage resilient to interrupted writes, missing files and stalled cleanup [#11344](https://github.com/penpot/penpot/issues/11344) (PR: [#11345](https://github.com/penpot/penpot/pull/11345)) +- Implement RTL support in the text editor v3 [#11262](https://github.com/penpot/penpot/issues/11262) +- Improve path operations and edition in the path editor [#10889](https://github.com/penpot/penpot/issues/10889) (PR: [#10807](https://github.com/penpot/penpot/pull/10807)) +- Add configurable keyboard shortcuts [#9924](https://github.com/penpot/penpot/issues/9924) (PR: [#10237](https://github.com/penpot/penpot/pull/10237)) +- Add auto-linking of libraries during import based on slugified name [#9263](https://github.com/penpot/penpot/issues/9263) (PR: [#9958](https://github.com/penpot/penpot/pull/9958)) + ## 2.18.0 (Unreleased) ### :bug: Bugs fixed @@ -11,29 +31,154 @@ - Fix plugin API addTheme calls failing with the signature shown in the high-level overview [#10074](https://github.com/penpot/penpot/issues/10074) (PR: [#10359](https://github.com/penpot/penpot/pull/10359)) - Fix empty text shape not being deleted on editor exit [#10540](https://github.com/penpot/penpot/issues/10540) (PR: [#10541](https://github.com/penpot/penpot/pull/10541)) - Fix broken token pills showing wrong default state when not selected [#10524](https://github.com/penpot/penpot/issues/10524) (PR: [#10535](https://github.com/penpot/penpot/pull/10535)) -- Replace hyphens with bullets in subscription benefits list [#10547](https://github.com/penpot/penpot/issues/10547) (PR: [#10523](https://github.com/penpot/penpot/pull/10523)) - Fix Chinese (zh-CN) translation showing wrong label for Intersection in board path menu (by @sawirricardo) [#10346](https://github.com/penpot/penpot/issues/10346) (PR: [#10381](https://github.com/penpot/penpot/pull/10381)) +- Fix invalid formulas being accepted in numeric inputs (by @AKnassa) [#9581](https://github.com/penpot/penpot/issues/9581) (PR: [#10659](https://github.com/penpot/penpot/pull/10659)) +- Fix radial gradient handles blowing up in size when rotated on ellipses (by @AKnassa) [#10069](https://github.com/penpot/penpot/issues/10069) (PR: [#10666](https://github.com/penpot/penpot/pull/10666)) +- Fix plugin API validation errors being too generic to diagnose the failure (by @AKnassa) [#10072](https://github.com/penpot/penpot/issues/10072) (PR: [#10667](https://github.com/penpot/penpot/pull/10667)) +- Fix crash with referential integrity error when deleting a component inside a grid (by @Alotor) [#10101](https://github.com/penpot/penpot/issues/10101) (PR: [#10956](https://github.com/penpot/penpot/pull/10956)) +- Fix component copies not preserving rotation when the main component has changes [#10109](https://github.com/penpot/penpot/issues/10109) (PR: [#10574](https://github.com/penpot/penpot/pull/10574)) +- Fix text width and height staying stale after setting growType in the plugin API [#10207](https://github.com/penpot/penpot/issues/10207) (PR: [#9898](https://github.com/penpot/penpot/pull/9898)) +- Fix padding not painted until expanding the 4-sides padding option [#10278](https://github.com/penpot/penpot/issues/10278) (PR: [#10602](https://github.com/penpot/penpot/pull/10602)) +- Fix files with custom fonts breaking with a referential integrity error when moved between teams (by @filipsajdak) [#10496](https://github.com/penpot/penpot/issues/10496) (PR: [#10837](https://github.com/penpot/penpot/pull/10837)) +- Fix clicking overlapping comment bubbles zooming to 20000% without showing the comments [#10526](https://github.com/penpot/penpot/issues/10526) (PR: [#10543](https://github.com/penpot/penpot/pull/10543)) +- Fix user menu subsections in the dashboard not closing when hovering away from the parent option (by @AKnassa) [#10549](https://github.com/penpot/penpot/issues/10549) (PR: [#10639](https://github.com/penpot/penpot/pull/10639)) +- Fix self-hosted env-generated config.js being cached for 7 days so PENPOT_FLAGS changes did not reach already-cached browsers (by @filipsajdak) [#10556](https://github.com/penpot/penpot/issues/10556) (PR: [#11146](https://github.com/penpot/penpot/pull/11146)) +- Fix color of selected text in light theme [#10570](https://github.com/penpot/penpot/issues/10570) (PR: [#10614](https://github.com/penpot/penpot/pull/10614)) +- Fix margin input order being inconsistent with padding inputs and between collapsed and expanded states [#10578](https://github.com/penpot/penpot/issues/10578) (PR: [#10797](https://github.com/penpot/penpot/pull/10797)) +- Fix uncaught DOMException when writing image/svg+xml content to the clipboard (by @AKnassa) [#10596](https://github.com/penpot/penpot/issues/10596) (PR: [#10663](https://github.com/penpot/penpot/pull/10663)) +- Fix tick icons not aligned in the font selector [#10597](https://github.com/penpot/penpot/issues/10597) (PR: [#10774](https://github.com/penpot/penpot/pull/10774)) +- Fix incorrect padding values when multiple shapes are selected [#10598](https://github.com/penpot/penpot/issues/10598) (PR: [#10602](https://github.com/penpot/penpot/pull/10602)) +- Fix integrity errors related to variants not being repaired [#10606](https://github.com/penpot/penpot/issues/10606) (PR: [#10768](https://github.com/penpot/penpot/pull/10768)) +- Fix changing password showing 'Password should be at least 8 characters' error on the old password field (by @AKnassa) [#10626](https://github.com/penpot/penpot/issues/10626) (PR: [#10661](https://github.com/penpot/penpot/pull/10661)) +- Fix stroke caps disappearing when dragging [#10633](https://github.com/penpot/penpot/issues/10633) (PR: [#10634](https://github.com/penpot/penpot/pull/10634)) +- Fix layout padding being saved as string after invalid input in multi-selection, causing persistence errors (by @niwinz) [#10638](https://github.com/penpot/penpot/issues/10638) (PR: [#10758](https://github.com/penpot/penpot/pull/10758)) +- Fix inconsistent theme handling between Penpot and plugins [#10676](https://github.com/penpot/penpot/issues/10676) (PR: [#10677](https://github.com/penpot/penpot/pull/10677)) +- Fix image stroke (strokeImage) support missing in the plugin API Stroke interface [#10682](https://github.com/penpot/penpot/issues/10682) (PR: [#10683](https://github.com/penpot/penpot/pull/10683)) +- Fix SVG images not working as fill in the WebGL renderer [#10705](https://github.com/penpot/penpot/issues/10705) (PR: [#10707](https://github.com/penpot/penpot/pull/10707)) +- Fix background blur not working on text shapes [#10706](https://github.com/penpot/penpot/issues/10706) (PR: [#10712](https://github.com/penpot/penpot/pull/10712)) +- Fix background blur not applying on strokes [#10713](https://github.com/penpot/penpot/issues/10713) (PR: [#10716](https://github.com/penpot/penpot/pull/10716)) +- Fix text shape with empty content breaking workspace updates [#10725](https://github.com/penpot/penpot/issues/10725) (PR: [#10731](https://github.com/penpot/penpot/pull/10731)) +- Fix missing SVG option in the file filters when adding an image fill (by @LuBoys) [#10756](https://github.com/penpot/penpot/issues/10756) (PR: [#10771](https://github.com/penpot/penpot/pull/10771)) +- Update onboarding image [#10779](https://github.com/penpot/penpot/issues/10779) (PR: [#10783](https://github.com/penpot/penpot/pull/10783)) +- Fix main toolbar overlapping the grid edition bar [#10788](https://github.com/penpot/penpot/issues/10788) (PR: [#10789](https://github.com/penpot/penpot/pull/10789)) +- Fix WASM renderer panic when the WebGL context is restored mid-reload [#10810](https://github.com/penpot/penpot/issues/10810) (PR: [#10824](https://github.com/penpot/penpot/pull/10824)) +- Fix nginx frontend forwarding the client Host header to backend/exporter, breaking Istio strict mTLS routing (by @yamila-moreno) [#10835](https://github.com/penpot/penpot/issues/10835) (PR: [#11233](https://github.com/penpot/penpot/pull/11233)) +- Fix tutorial templates with components causing errors [#10839](https://github.com/penpot/penpot/issues/10839) +- Fix plugin 'Try out' flow crashing when projects have not loaded yet [#10858](https://github.com/penpot/penpot/issues/10858) (PR: [#10859](https://github.com/penpot/penpot/pull/10859)) +- Fix collapsed Fill color section on the design panel for new texts [#10860](https://github.com/penpot/penpot/issues/10860) (PR: [#10972](https://github.com/penpot/penpot/pull/10972)) +- Fix grid item date tooltip in the project view showing 'Will be deleted' instead of creation date (by @0xTHAC0) [#10873](https://github.com/penpot/penpot/issues/10873) (PR: [#11161](https://github.com/penpot/penpot/pull/11161)) +- Merge stop and start measurement shortcut to match current behavior [#10884](https://github.com/penpot/penpot/issues/10884) (PR: [#10906](https://github.com/penpot/penpot/pull/10906)) +- Fix shape size badge displayed twice when a user with Viewer permissions selects a shape [#10893](https://github.com/penpot/penpot/issues/10893) (PR: [#10985](https://github.com/penpot/penpot/pull/10985)) +- Fix main menu being covered by the toolbar [#10902](https://github.com/penpot/penpot/issues/10902) (PR: [#10926](https://github.com/penpot/penpot/pull/10926)) +- Fix font family typography asset persisting across files in newly created text layers [#10925](https://github.com/penpot/penpot/issues/10925) (PR: [#11134](https://github.com/penpot/penpot/pull/11134)) +- Fix error raised when editing justified text [#10944](https://github.com/penpot/penpot/issues/10944) (PR: [#10945](https://github.com/penpot/penpot/pull/10945)) +- Fix MCP WebSocket proxy failing after penpot-mcp container restarts due to stale nginx DNS resolution (by @780Farva) [#10946](https://github.com/penpot/penpot/issues/10946) (PR: [#10947](https://github.com/penpot/penpot/pull/10947)) +- Fix verification email address being unreadable due to low-contrast text on the register success page [#10950](https://github.com/penpot/penpot/issues/10950) (PR: [#10965](https://github.com/penpot/penpot/pull/10965)) +- Fix image swatches displaying a wrong format in the color picker list view [#10951](https://github.com/penpot/penpot/issues/10951) (PR: [#10975](https://github.com/penpot/penpot/pull/10975)) +- Fix text editor crashing when dropping dragged text after selecting all content [#10954](https://github.com/penpot/penpot/issues/10954) (PR: [#10959](https://github.com/penpot/penpot/pull/10959)) +- Fix MCP tokens being usable as API access tokens [#10960](https://github.com/penpot/penpot/issues/10960) (PR: [#10962](https://github.com/penpot/penpot/pull/10962)) +- Add size limit and rate limiting to the send-user-feedback endpoint [#10979](https://github.com/penpot/penpot/issues/10979) (PR: [#10990](https://github.com/penpot/penpot/pull/10990)) +- Fix main menu not keeping alignment when the left sidebar is expanded [#10981](https://github.com/penpot/penpot/issues/10981) (PR: [#10986](https://github.com/penpot/penpot/pull/10986)) +- Fix update-profile-props RPC method accepting undocumented keys [#10991](https://github.com/penpot/penpot/issues/10991) (PR: [#10992](https://github.com/penpot/penpot/pull/10992)) +- Fix import-binfile RPC method schema accepting a file-id parameter [#10993](https://github.com/penpot/penpot/issues/10993) (PR: [#10994](https://github.com/penpot/penpot/pull/10994)) +- Fix assemble-chunks session lookup ignoring the profile-id scope [#11011](https://github.com/penpot/penpot/issues/11011) (PR: [#11012](https://github.com/penpot/penpot/pull/11012)) +- Validate font-id team ownership in create-font-variant [#11013](https://github.com/penpot/penpot/issues/11013) (PR: [#11014](https://github.com/penpot/penpot/pull/11014)) +- Validate team ownership on file library link endpoints [#11015](https://github.com/penpot/penpot/issues/11015) (PR: [#11016](https://github.com/penpot/penpot/pull/11016)) +- Limit object size allocation in the V1 binfile parser [#11017](https://github.com/penpot/penpot/issues/11017) (PR: [#11018](https://github.com/penpot/penpot/pull/11018)) +- Limit recursion depth in the Fressian reader [#11019](https://github.com/penpot/penpot/issues/11019) (PR: [#11020](https://github.com/penpot/penpot/pull/11020)) +- Limit concurrent imports in the import-binfile RPC method [#11023](https://github.com/penpot/penpot/issues/11023) (PR: [#11024](https://github.com/penpot/penpot/pull/11024)) +- Validate content-type on management upload endpoints [#11025](https://github.com/penpot/penpot/issues/11025) (PR: [#11026](https://github.com/penpot/penpot/pull/11026)) +- Fix webhook endpoints allowing unauthorized access via creator-id fallback [#11028](https://github.com/penpot/penpot/issues/11028) (PR: [#11029](https://github.com/penpot/penpot/pull/11029)) +- Escape markdown in user-controlled fields of Mattermost error notifications [#11033](https://github.com/penpot/penpot/issues/11033) (PR: [#11034](https://github.com/penpot/penpot/pull/11034)) +- Enforce file read permission check on asset endpoints [#11035](https://github.com/penpot/penpot/issues/11035) (PR: [#11036](https://github.com/penpot/penpot/pull/11036)) +- Add accumulated storage byte quota for media uploads [#11037](https://github.com/penpot/penpot/issues/11037) (PR: [#11038](https://github.com/penpot/penpot/pull/11038)) +- Add bounding box dimension limit to exports [#11041](https://github.com/penpot/penpot/issues/11041) (PR: [#11042](https://github.com/penpot/penpot/pull/11042)) +- Sanitize embedded scripts in SVG uploads [#11043](https://github.com/penpot/penpot/issues/11043) (PR: [#11044](https://github.com/penpot/penpot/pull/11044)) +- Fix duplicate file ID returning inconsistent error responses [#11045](https://github.com/penpot/penpot/issues/11045) (PR: [#11050](https://github.com/penpot/penpot/pull/11050)) +- Enforce permission checks in WebSocket subscription handlers [#11052](https://github.com/penpot/penpot/issues/11052) (PR: [#11054](https://github.com/penpot/penpot/pull/11054)) +- Fix 'something went wrong' popup when using incremental numerical input interaction [#11053](https://github.com/penpot/penpot/issues/11053) (PR: [#10794](https://github.com/penpot/penpot/pull/10794)) +- Enforce password complexity validation on the backend [#11055](https://github.com/penpot/penpot/issues/11055) (PR: [#11059](https://github.com/penpot/penpot/pull/11059)) +- Normalize string inputs before processing [#11060](https://github.com/penpot/penpot/issues/11060) (PR: [#11061](https://github.com/penpot/penpot/pull/11061)) +- Add cooldown to avoid sending duplicate invitation emails [#11062](https://github.com/penpot/penpot/issues/11062) (PR: [#11063](https://github.com/penpot/penpot/pull/11063)) +- Enable SSRF protection for organization SSO validation [#11064](https://github.com/penpot/penpot/issues/11064) (PR: [#11065](https://github.com/penpot/penpot/pull/11065)) +- Fix clone-file-media-object allowing to clone media objects from files without read access [#11087](https://github.com/penpot/penpot/issues/11087) (PR: [#11090](https://github.com/penpot/penpot/pull/11090)) +- Fix 404 error page logo not visible in dark mode [#11091](https://github.com/penpot/penpot/issues/11091) (PR: [#11167](https://github.com/penpot/penpot/pull/11167)) +- Fix incorrect permission handling when creating an invitation [#11098](https://github.com/penpot/penpot/issues/11098) (PR: [#11099](https://github.com/penpot/penpot/pull/11099)) +- Reject zero or negative total-chunks values in upload sessions [#11103](https://github.com/penpot/penpot/issues/11103) (PR: [#11104](https://github.com/penpot/penpot/pull/11104)) +- Fix import-binfile accepting unsupported version values without validation [#11105](https://github.com/penpot/penpot/issues/11105) (PR: [#11107](https://github.com/penpot/penpot/pull/11107)) +- Fix sessions remaining active on other devices after account deletion [#11114](https://github.com/penpot/penpot/issues/11114) (PR: [#11115](https://github.com/penpot/penpot/pull/11115)) +- Use random UUIDs for share link IDs instead of a predictable scheme [#11116](https://github.com/penpot/penpot/issues/11116) (PR: [#11117](https://github.com/penpot/penpot/pull/11117)) +- Fix plugin manifest fetch hanging indefinitely without timeout [#11119](https://github.com/penpot/penpot/issues/11119) (PR: [#11120](https://github.com/penpot/penpot/pull/11120)) +- Use constant-time comparison for shared key authentication [#11121](https://github.com/penpot/penpot/issues/11121) (PR: [#11122](https://github.com/penpot/penpot/pull/11122)) +- Fix ESC key not closing the comment input box after posting a comment in the workspace [#11128](https://github.com/penpot/penpot/issues/11128) (PR: [#11131](https://github.com/penpot/penpot/pull/11131)) +- Fix token edit modal crashing when resolving tokens with group nodes [#11143](https://github.com/penpot/penpot/issues/11143) (PR: [#11144](https://github.com/penpot/penpot/pull/11144)) +- Fix text editor crashing when pasting into an empty text shape [#11149](https://github.com/penpot/penpot/issues/11149) (PR: [#11150](https://github.com/penpot/penpot/pull/11150)) +- Fix comment avatars appearing on top of rulers when scrolling the canvas (by @filipsajdak) [#11163](https://github.com/penpot/penpot/issues/11163) (PR: [#11168](https://github.com/penpot/penpot/pull/11168)) +- Fix infinite loop of get-teams and get-team-members calls when granting team access from an email link [#11215](https://github.com/penpot/penpot/issues/11215) (PR: [#11223](https://github.com/penpot/penpot/pull/11223)) +- Fix RPC requests bypassing rate limiting with fractional bucket refill intervals [#11253](https://github.com/penpot/penpot/issues/11253) (PR: [#11254](https://github.com/penpot/penpot/pull/11254)) +- Fix tempfile bucket serving objects to any authenticated user instead of only the uploader [#11269](https://github.com/penpot/penpot/issues/11269) (PR: [#11270](https://github.com/penpot/penpot/pull/11270)) +- Fix increasing a value by clicking and dragging in a numeric input [#11274](https://github.com/penpot/penpot/issues/11274) (PR: [#11334](https://github.com/penpot/penpot/pull/11334)) +- Fix notification pill rendering unescaped HTML in the detail section when importing tokens [#11276](https://github.com/penpot/penpot/issues/11276) (PR: [#11275](https://github.com/penpot/penpot/pull/11275)) +- Fix share-link holders reading pages outside the authorized scope via the get-page RPC command [#11281](https://github.com/penpot/penpot/issues/11281) (PR: [#11284](https://github.com/penpot/penpot/pull/11284)) +- Fix incorrect permission handling when managing share links on a file [#11289](https://github.com/penpot/penpot/issues/11289) (PR: [#11290](https://github.com/penpot/penpot/pull/11290)) +- Fix backend session remaining valid after logout when the auth-token cookie is replayed [#11316](https://github.com/penpot/penpot/issues/11316) (PR: [#11317](https://github.com/penpot/penpot/pull/11317)) +- Fix get-team-invitation-token requiring only read permissions [#11358](https://github.com/penpot/penpot/issues/11358) (PR: [#11359](https://github.com/penpot/penpot/pull/11359)) ### :sparkles: New features & Enhancements - Group toolbar drawing tools into shape and free-draw flyouts [#9316](https://github.com/penpot/penpot/issues/9316) (PR: [#9480](https://github.com/penpot/penpot/pull/9480), [#10354](https://github.com/penpot/penpot/pull/10354)) - Add outline stroke to Paths [#9961](https://github.com/penpot/penpot/issues/9961) (PR: [#8677](https://github.com/penpot/penpot/pull/8677)) -- Make throwValidationErrors default to true for v2 manifest plugins [#10401](https://github.com/penpot/penpot/issues/10401) (PR: [#10433](https://github.com/penpot/penpot/pull/10433)) - Add dedicated Line and Arrow drawing tools (by @davidv399) [#9145](https://github.com/penpot/penpot/issues/9145) (PR: [#9146](https://github.com/penpot/penpot/pull/9146)) - Refactor wasm rulers and UI state [#10116](https://github.com/penpot/penpot/issues/10116) (PR: [#10461](https://github.com/penpot/penpot/pull/10461)) - Improve team invitations modal in the dashboard [#10484](https://github.com/penpot/penpot/issues/10484) (PR: [#10459](https://github.com/penpot/penpot/pull/10459)) - - -## 2.17.1 (Unreleased) +- Highlight the first matching font in the font list when searching (by @ai-mountain) [#3204](https://github.com/penpot/penpot/issues/3204) (PR: [#9512](https://github.com/penpot/penpot/pull/9512), [#10450](https://github.com/penpot/penpot/pull/10450)) +- Preserve token references when copying and pasting properties instead of resolving them to values (by @AKnassa) [#9582](https://github.com/penpot/penpot/issues/9582) (PR: [#10665](https://github.com/penpot/penpot/pull/10665)) +- Add waitForLayoutUpdate method to the plugin API [#10136](https://github.com/penpot/penpot/issues/10136) (PR: [#9898](https://github.com/penpot/penpot/pull/9898)) +- Show and manage comments while designing in the workspace [#10239](https://github.com/penpot/penpot/issues/10239) (PR: [#10275](https://github.com/penpot/penpot/pull/10275)) +- Simplify MCP server configuration for common MCP clients [#10355](https://github.com/penpot/penpot/issues/10355) (PR: [#10604](https://github.com/penpot/penpot/pull/10604)) +- Remove misleading MCP client JSON snippet from the key-generated modal (by @Shlok1729) [#10399](https://github.com/penpot/penpot/issues/10399) (PR: [#10415](https://github.com/penpot/penpot/pull/10415)) +- Preview font families in the font selector [#10403](https://github.com/penpot/penpot/issues/10403) (PR: [#10411](https://github.com/penpot/penpot/pull/10411)) +- Remember expanded/collapsed state of token sets in the color tokens picker (session scope) [#10551](https://github.com/penpot/penpot/issues/10551) (PR: [#10864](https://github.com/penpot/penpot/pull/10864)) +- Show token sets in reverse order by default in the color tokens picker (by @rhinocap) [#10552](https://github.com/penpot/penpot/issues/10552) (PR: [#10658](https://github.com/penpot/penpot/pull/10658)) +- Add multi-selection and bulk delete support to pages in the workspace sitemap [#10580](https://github.com/penpot/penpot/issues/10580) (PR: [#10581](https://github.com/penpot/penpot/pull/10581)) +- Add a grid/list view toggle for files in the dashboard [#10691](https://github.com/penpot/penpot/issues/10691) (PR: [#10692](https://github.com/penpot/penpot/pull/10692)) +- Migrate Docker images to Docker Hardened Images (DHI) [#10720](https://github.com/penpot/penpot/issues/10720) (PR: [#10732](https://github.com/penpot/penpot/pull/10732), [#10733](https://github.com/penpot/penpot/pull/10733), [#10734](https://github.com/penpot/penpot/pull/10734)) +- Adopt React Aria [#10802](https://github.com/penpot/penpot/issues/10802) (PR: [#10675](https://github.com/penpot/penpot/pull/10675)) +- Add plugin API function for awaiting component updates beyond waitForLayoutUpdate [#10927](https://github.com/penpot/penpot/issues/10927) (PR: [#10964](https://github.com/penpot/penpot/pull/10964)) +- Emit open-workspace-file audit event with file statistics on workspace load [#11106](https://github.com/penpot/penpot/issues/11106) (PR: [#11138](https://github.com/penpot/penpot/pull/11138)) +## 2.17.2 ### :bug: Bugs fixed +- Fix linear gradients in SVG text exports being emitted as radial gradients [#5972](https://github.com/penpot/penpot/issues/5972) (PR: [#11272](https://github.com/penpot/penpot/pull/11272)) +- Fix typography token becoming detached when editing text content [#11362](https://github.com/penpot/penpot/issues/11362) (PR: [#11366](https://github.com/penpot/penpot/pull/11366)) +- Fix command injection in SVG exporter via legacy fill-color (https://github.com/penpot/penpot/security/advisories/GHSA-4f36-m4hj-cv86) + +## 2.17.1 + +### :bug: Bugs fixed + +- Fix overrides lost after switching component variant [#10588](https://github.com/penpot/penpot/issues/10588) (PR: [#10619](https://github.com/penpot/penpot/pull/10619)) - Fix malformed get-font-variants request when team-id is missing from dashboard URL [#10644](https://github.com/penpot/penpot/issues/10644) (PR: [#10645](https://github.com/penpot/penpot/pull/10645)) - Fix malformed get-profiles-for-file-comments request when file-id is missing from workspace URL [#10652](https://github.com/penpot/penpot/issues/10652) (PR: [#10655](https://github.com/penpot/penpot/pull/10655)) -- Fix workspace crash when holding an arrow key on a selection due to excessive re-renders [#10726](https://github.com/penpot/penpot/issues/10726) (PR: [#10736](https://github.com/penpot/penpot/pull/10736)) -- Fix asset download failing with S3 auth conflict when using access token [#10776](https://github.com/penpot/penpot/issues/10776) (PR: [#10777](https://github.com/penpot/penpot/pull/10777)) - Fix internal error when dragging inner layout with Boolean operations [#10647](https://github.com/penpot/penpot/issues/10647) (PR: [#10778](https://github.com/penpot/penpot/pull/10778)) +- Fix frontend throwing raw TypeError on undefined .getData receivers across import, paste, drag, and text editor paths [#10709](https://github.com/penpot/penpot/issues/10709) (PR: [#10718](https://github.com/penpot/penpot/pull/10718)) +- Fix workspace crash with 'can't access dead object' in Firefox when navigating between pages [#10719](https://github.com/penpot/penpot/issues/10719) (PR: [#10721](https://github.com/penpot/penpot/pull/10721)) +- Fix workspace crash when holding an arrow key on a selection due to excessive re-renders [#10726](https://github.com/penpot/penpot/issues/10726) (PR: [#10736](https://github.com/penpot/penpot/pull/10736)) +- Fix dashboard sidebar throwing removeChild NotFoundError during rapid keyboard navigation [#10714](https://github.com/penpot/penpot/issues/10714) (PR: [#10715](https://github.com/penpot/penpot/pull/10715)) +- Fix asset download failing with S3 auth conflict when using access token [#10776](https://github.com/penpot/penpot/issues/10776) (PR: [#10777](https://github.com/penpot/penpot/pull/10777)) +- Fix import worker crashing when importing non-Penpot zip files [#10781](https://github.com/penpot/penpot/issues/10781) (PR: [#10782](https://github.com/penpot/penpot/pull/10782)) - Fix viewer crash with WASM panic when opening URL with page-id [#10800](https://github.com/penpot/penpot/issues/10800) (PR: [#10805](https://github.com/penpot/penpot/pull/10805)) +- Fix backend returning 500 when JSON request body has unrecognized escape sequence [#10804](https://github.com/penpot/penpot/issues/10804) (PR: [#10808](https://github.com/penpot/penpot/pull/10808)) +- Fix color picker eyedropper crashing when viewport is unmounted during pointer move [#10811](https://github.com/penpot/penpot/issues/10811) (PR: [#10812](https://github.com/penpot/penpot/pull/10812)) +- Fix flex layout crash when dragging shapes with missing bounds [#10843](https://github.com/penpot/penpot/issues/10843) (PR: [#10845](https://github.com/penpot/penpot/pull/10845)) +- Fix export failing when shape has blank layer name [#10849](https://github.com/penpot/penpot/issues/10849) (PR: [#10852](https://github.com/penpot/penpot/pull/10852)) +- Fix area selection (marquee) being aborted by select-shapes interrupt [#10872](https://github.com/penpot/penpot/issues/10872) (PR: [#10870](https://github.com/penpot/penpot/pull/10870)) +- Fix gradient editor sending invalid stop offset when clicking outside gradient line [#10879](https://github.com/penpot/penpot/issues/10879) (PR: [#10881](https://github.com/penpot/penpot/pull/10881)) +- Fix audit event validation failing when error reports contain string profile-id and missing token context [#10897](https://github.com/penpot/penpot/issues/10897) (PR: [#10898](https://github.com/penpot/penpot/pull/10898)) +- Fix MCP tool call timeout being too low for some operations [#10953](https://github.com/penpot/penpot/issues/10953) (PR: [#10967](https://github.com/penpot/penpot/pull/10967)) +- Fix MCP requests running into timeouts after leaving a file in Penpot [#10958](https://github.com/penpot/penpot/issues/10958) (PR: [#10967](https://github.com/penpot/penpot/pull/10967)) +- Fix duplicate WebSocket MCP connection attempts deregistering the original connection's routing entries [#10961](https://github.com/penpot/penpot/issues/10961) (PR: [#10967](https://github.com/penpot/penpot/pull/10967)) ## 2.17.0 @@ -3011,7 +3156,7 @@ is a number of cores) - Enable penpot SVG metadata only when exporting complete files [Taiga #1914](https://tree.taiga.io/project/penpot/us/1914?milestone=295883) - Export to PDF all artboards of one page [Taiga #1895](https://tree.taiga.io/project/penpot/us/1895) - Go to a undo step clicking on a history element of the list [Taiga #1374](https://tree.taiga.io/project/penpot/us/1374) -- Increment font size by 10 with shift+arrows [1047](https://github.com/penpot/penpot/issues/1047) +- Increment font size by 10 with shift+arrows [#1047](https://github.com/penpot/penpot/issues/1047) - New shortcut to detach components Ctrl+Shift+K [Taiga #1799](https://tree.taiga.io/project/penpot/us/1799) - Set email inputs to type "email", to aid keyboard entry [Taiga #1921](https://tree.taiga.io/project/penpot/issue/1921) - Use shift+move to move element orthogonally [#823](https://github.com/penpot/penpot/issues/823) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63b931900f..d905aae9e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,7 @@ Center](https://help.penpot.app/). - [Reporting Bugs](#reporting-bugs) - [Pull Requests](#pull-requests) - [Workflow](#workflow) + - [Branch naming](#branch-naming) - [Format](#format) - [Title format](#title-format) - [Description](#description) @@ -73,6 +74,18 @@ Advisories](https://github.com/penpot/penpot/security/advisories) 4. **Format and lint** — run the checks described in [Formatting and Linting](#formatting-and-linting) before submitting. +### Branch naming + +Branch names are not enforced, but we recommend the following: + +- **`issue-NNNN`** — when working from a GitHub issue, name the branch after + it (e.g. `issue-11525`). This makes each PR's origin self-evident. +- Otherwise, use a short, descriptive name with words separated by hyphens + and no slashes (e.g. `fix-ellipse-icon-typo`, `feat-auto-link-libraries`). + +Since PRs are squash-merged, the branch name does not survive into the +commit history — what matters is the [PR title](#title-format). + ### Format #### Title diff --git a/HIGHLIGHTS.md b/HIGHLIGHTS.md new file mode 100644 index 0000000000..e66b4b4497 --- /dev/null +++ b/HIGHLIGHTS.md @@ -0,0 +1,26 @@ +# HIGHLIGHTS + +## 2.17.0 + +- Background blur is here +- WebGL rendering gets stronger +- MCP connection status and more +- Design tokens: more visible, more user-friendly + + +## 2.16.0 + +- Design tokens in the design panel +- Major community contributions +- WebGL rendering (beta) + + +## 2.15.0 + +- AI connected to real design context +- Multi-directional workflow +- Your stack, your model, your decision + + + + diff --git a/README.md b/README.md index 5a1d6d60c2..0def50aa6a 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,6 @@ This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. -Copyright (c) KALEIDOS INC Sucursal en España SL +Copyright (c) KALEIDOS SUBSIDIARY SL ``` Penpot is a Kaleidos’ [open source project](https://kaleidos.net/) diff --git a/backend/deps.edn b/backend/deps.edn index 2599066e0b..f10437fb75 100644 --- a/backend/deps.edn +++ b/backend/deps.edn @@ -17,7 +17,7 @@ io.prometheus/simpleclient_httpserver {:mvn/version "0.16.0"} - io.lettuce/lettuce-core {:mvn/version "7.6.0.RELEASE"} + io.lettuce/lettuce-core {:mvn/version "7.7.0.RELEASE"} ;; Minimal dependencies required by lettuce, we need to include them ;; explicitly because clojure dependency management does not support ;; yet the BOM format. @@ -25,7 +25,7 @@ io.micrometer/micrometer-observation {:mvn/version "1.14.2"} java-http-clj/java-http-clj {:mvn/version "0.4.3"} - com.google.guava/guava {:mvn/version "33.6.0-jre"} + com.google.guava/guava {:mvn/version "33.7.1-jre"} funcool/yetti {:git/tag "v11.10" @@ -40,37 +40,44 @@ nrepl/nrepl {:mvn/version "1.7.0"} org.postgresql/postgresql {:mvn/version "42.7.13"} - org.xerial/sqlite-jdbc {:mvn/version "3.53.2.1"} + org.xerial/sqlite-jdbc {:mvn/version "3.53.4.0"} com.zaxxer/HikariCP {:mvn/version "7.1.0"} - io.whitfin/siphash {:mvn/version "2.0.0"} + io.whitfin/siphash {:mvn/version "3.0.0"} buddy/buddy-hashers {:mvn/version "2.0.167"} buddy/buddy-sign {:mvn/version "3.6.1-359"} + org.passay/passay {:mvn/version "2.0.0"} com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"} - org.jsoup/jsoup {:mvn/version "1.23.1"} + org.jsoup/jsoup {:mvn/version "1.23.2"} at.yawk.lz4/lz4-java - {:mvn/version "1.11.1"} + {:mvn/version "1.11.2"} org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"} dawran6/emoji {:mvn/version "0.2.0"} - markdown-clj/markdown-clj {:mvn/version "1.12.8"} + markdown-clj/markdown-clj {:mvn/version "1.12.9"} ;; Pretty Print specs pretty-spec/pretty-spec {:mvn/version "0.1.4"} - software.amazon.awssdk/s3 {:mvn/version "2.50.1"} - software.amazon.awssdk/sts {:mvn/version "2.50.1"}} + software.amazon.awssdk/s3 {:mvn/version "2.54.5"} + software.amazon.awssdk/sts {:mvn/version "2.54.5"} + + com.ladybugdb/lbug {:mvn/version "0.19.1"} + ;; Required by Arrow RootAllocator (lbug only pulls arrow-memory-core). + org.apache.arrow/arrow-memory-netty {:mvn/version "18.2.0"}} :paths ["src" "resources" "target/classes"] :aliases {:dev {:jvm-opts ["--sun-misc-unsafe-memory-access=allow" - "--enable-native-access=ALL-UNNAMED"] + "--enable-native-access=ALL-UNNAMED" + ;; Arrow jars are on the classpath (unnamed module), not module-path. + "--add-opens=java.base/java.nio=ALL-UNNAMED"] :extra-deps {com.bhauman/rebel-readline {:mvn/version "0.1.11"} clojure-humanize/clojure-humanize {:mvn/version "0.2.2"} diff --git a/backend/dev/script-fix-sobjects.clj b/backend/dev/script-fix-sobjects.clj index 3194ce0716..114e21684a 100644 --- a/backend/dev/script-fix-sobjects.clj +++ b/backend/dev/script-fix-sobjects.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL ;; This is an example on how it can be executed: ;; clojure -Scp $(cat classpath) -M dev/script-fix-sobjects.clj diff --git a/backend/dev/user.clj b/backend/dev/user.clj index 16908f4cab..9e13c8e7af 100644 --- a/backend/dev/user.clj +++ b/backend/dev/user.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns user (:require diff --git a/backend/package.json b/backend/package.json index 9fbce288ab..c6baf43f73 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,23 +4,25 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" }, "dependencies": { - "luxon": "^3.4.4", - "sax": "^1.6.0" + "eventsource-parser": "^3.0.6", + "luxon": "^3.7.2", + "sax": "^1.6.1" }, "devDependencies": { "nodemon": "^3.1.14", "source-map-support": "^0.5.21", - "ws": "^8.21.0" + "ws": "^8.21.1" }, "scripts": { "lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint ../common/src src/", "check-fmt:clj": "cljfmt check --parallel=true src/ test/", - "fmt:clj": "cljfmt fix --parallel=true src/ test/" + "fmt:clj": "cljfmt fix --parallel=true src/ test/", + "test:e2e": "node --test --test-concurrency=1 test/e2e/*.test.mjs" } } diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index d789e2c2f8..a0b16465e9 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -8,12 +8,15 @@ importers: .: dependencies: + eventsource-parser: + specifier: ^3.0.6 + version: 3.1.0 luxon: - specifier: ^3.4.4 + specifier: ^3.7.2 version: 3.7.2 sax: - specifier: ^1.6.0 - version: 1.6.0 + specifier: ^1.6.1 + version: 1.6.1 devDependencies: nodemon: specifier: ^3.1.14 @@ -22,8 +25,8 @@ importers: specifier: ^0.5.21 version: 0.5.21 ws: - specifier: ^8.21.0 - version: 8.21.0 + specifier: ^8.21.1 + version: 8.21.1 packages: @@ -39,9 +42,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -63,6 +66,10 @@ packages: supports-color: optional: true + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -130,8 +137,8 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} - sax@1.6.0: - resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} semver@7.8.5: @@ -165,8 +172,8 @@ packages: undefsafe@2.0.5: resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -188,7 +195,7 @@ snapshots: binary-extensions@2.3.0: {} - brace-expansion@5.0.7: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -216,6 +223,8 @@ snapshots: optionalDependencies: supports-color: 5.5.0 + eventsource-parser@3.1.0: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -247,7 +256,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.9 ms@2.1.3: {} @@ -274,7 +283,7 @@ snapshots: dependencies: picomatch: 2.3.2 - sax@1.6.0: {} + sax@1.6.1: {} semver@7.8.5: {} @@ -301,4 +310,4 @@ snapshots: undefsafe@2.0.5: {} - ws@8.21.0: {} + ws@8.21.1: {} diff --git a/backend/pnpm-workspace.yaml b/backend/pnpm-workspace.yaml index e69de29bb2..b3fbd9192b 100644 --- a/backend/pnpm-workspace.yaml +++ b/backend/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +minimumReleaseAgeExclude: + - brace-expansion@5.0.8 || 5.0.9 diff --git a/backend/resources/app/email/request-file-access-yourpenpot-view/en.html b/backend/resources/app/email/request-file-access-yourpenpot-view/en.html index 57f0bbb781..f26a4b65da 100644 --- a/backend/resources/app/email/request-file-access-yourpenpot-view/en.html +++ b/backend/resources/app/email/request-file-access-yourpenpot-view/en.html @@ -191,7 +191,7 @@ file named “{{file-name|abbreviate:25}}”. </p> <p> - Since this file is in your Penpot team, you can provide access by sending a view-only link. + Since this file is in your Personal Projects, you can provide access by sending a view-only link. This will allow {{requested-by|abbreviate:25}} to view the content without making any changes. </p> <p>To proceed, please click the button below to generate and send the view-only link:</p> diff --git a/backend/resources/app/email/request-file-access-yourpenpot-view/en.txt b/backend/resources/app/email/request-file-access-yourpenpot-view/en.txt index 397f3c821d..c52649dd37 100644 --- a/backend/resources/app/email/request-file-access-yourpenpot-view/en.txt +++ b/backend/resources/app/email/request-file-access-yourpenpot-view/en.txt @@ -2,7 +2,7 @@ Hello! {{requested-by|abbreviate:25}} ({{requested-by-email}}) wants to have view-only access to the file named “{{file-name|abbreviate:25}}”. -Since this file is in your Penpot team, you can provide access by sending a view-only link. This will allow {{requested-by|abbreviate:25}} to view the content without making any changes. +Since this file is in your Personal Projects, you can provide access by sending a view-only link. This will allow {{requested-by|abbreviate:25}} to view the content without making any changes. To proceed, please click the link below to generate and send the view-only link: diff --git a/backend/resources/app/email/request-file-access-yourpenpot/en.html b/backend/resources/app/email/request-file-access-yourpenpot/en.html index ca0b00ee27..cf3161a6b6 100644 --- a/backend/resources/app/email/request-file-access-yourpenpot/en.html +++ b/backend/resources/app/email/request-file-access-yourpenpot/en.html @@ -191,7 +191,7 @@ “{{file-name|abbreviate:25}}”. </p> <p> - Please note that the file is currently in Your Penpot 's team, so direct access cannot be + Please note that the file is currently in Personal Projects, so direct access cannot be granted. However, you have two options to provide the requested access: </p> <ul> diff --git a/backend/resources/app/email/request-file-access-yourpenpot/en.txt b/backend/resources/app/email/request-file-access-yourpenpot/en.txt index 81f5d5c72e..e33a0bf80a 100644 --- a/backend/resources/app/email/request-file-access-yourpenpot/en.txt +++ b/backend/resources/app/email/request-file-access-yourpenpot/en.txt @@ -5,7 +5,7 @@ Hello! {{requested-by|abbreviate:25}} ({{requested-by-email}}) has requested access to the file named “{{file-name|abbreviate:25}}”. -Please note that the file is currently in Your Penpot 's team, so direct access cannot be granted. However, you have two options to provide the requested access: +Please note that the file is currently in Personal Projects, so direct access cannot be granted. However, you have two options to provide the requested access: - Move the File to Another Team: diff --git a/backend/resources/app/templates/debug.tmpl b/backend/resources/app/templates/debug.tmpl index 42894b570c..64ddd9d150 100644 --- a/backend/resources/app/templates/debug.tmpl +++ b/backend/resources/app/templates/debug.tmpl @@ -190,6 +190,20 @@ Debug Main Page </div> </form> </fieldset> + + <fieldset> + <legend>Validate file:</legend> + <desc>Given an FILE-ID, check the referential integrity.</desc> + <form method="get" action="/dbg/actions/file-validate"> + <div class="row"> + <input type="text" style="width:300px" name="file-id" placeholder="file-id" /> + </div> + <div class="row"> + <input type="submit" name="validate" value="Validate" /> + </div> + </form> + </fieldset> + </section> <section class="widget"> <fieldset> @@ -222,6 +236,23 @@ Debug Main Page </div> </form> </fieldset> + {% if graph-enabled %} + <fieldset> + <legend>Export graph (Ladybug):</legend> + <desc>Given a FILE-ID, builds the graph projection and downloads + the `.lbug` database file.</desc> + + <form method="get" action="/dbg/actions/graph-export"> + <div class="row"> + <input type="text" style="width:300px" name="file-id" placeholder="file-id" /> + </div> + <div class="row"> + <input type="submit" value="Download .lbug" /> + <a href="/dbg/graph">Open graph console</a> + </div> + </form> + </fieldset> + {% endif %} <fieldset> <legend>Import binfile:</legend> <desc>Import penpot file in binary format.</desc> @@ -236,6 +267,89 @@ Debug Main Page </div> </form> </fieldset> + + <fieldset> + <legend>Repair file:</legend> + <desc>Given an FILE-ID, repair the referential integrity errors. + <br/> + <br/> + <b>WARNING: the reparation is not guaranteed and may cause loss of data!</b> + <br/> + <br/> + You may need to give several repair rounds until all errors are cleared. + </desc> + <form method="get" action="/dbg/actions/file-repair"> + <div class="row"> + <input type="text" style="width:300px" name="file-id" placeholder="file-id" /> + </div> + <div class="row"> + <label for="check-snapshot">Skip snapshot</label> + <input id="check-snapshot" type="checkbox" name="skip-snapshot" /> + <br /> + <small> + A snapshot is made just before the validation, unless skipped. + </small> + </div> + <div class="row"> + <input type="submit" name="repair" value="Repair" /> + </div> + </form> + </fieldset> + </section> + +</main> + +<main class="dashboard wide"> + <section class="widget wide"> + <fieldset> + <legend>Export jobs:</legend> + <desc> + Export jobs as the exporter left them in redis. Records expire an hour + after the export settles, so this is a live view, not a history. + </desc> + + <form method="get" action="/dbg"> + <div class="row"> + <input type="text" style="width:300px" name="job-id" + placeholder="filter by job id" value="{{export-job-filter}}" /> + <input type="submit" value="Filter" /> + <a href="/dbg">clear</a> + </div> + </form> + + <div class="scroll-box"> + <table> + <thead> + <tr> + <th>JOB ID</th> + <th>STATE</th> + <th>PROGRESS</th> + <th>CMD</th> + <th>BACKEND</th> + <th>NAME</th> + <th>CREATED</th> + <th>ENDED</th> + </tr> + </thead> + <tbody> + {% for job in export-jobs %} + <tr> + <td><tt>{{job.id}}</tt></td> + <td>{{job.state}}{% if job.interrupted %} (interrupted){% endif %}</td> + <td>{{job.done}} / {{job.total}}</td> + <td>{{job.cmd}}</td> + <td>{{job.backend}}</td> + <td>{{job.name}}</td> + <td>{{job.created-at}}</td> + <td>{{job.ended-at}}</td> + </tr> + {% empty %} + <tr><td colspan="8">No export jobs.</td></tr> + {% endfor %} + </tbody> + </table> + </div> + </fieldset> </section> </main> {% endblock %} diff --git a/backend/resources/app/templates/graph-console.tmpl b/backend/resources/app/templates/graph-console.tmpl new file mode 100644 index 0000000000..8746e65ec3 --- /dev/null +++ b/backend/resources/app/templates/graph-console.tmpl @@ -0,0 +1,1758 @@ +{% extends "app/templates/base.tmpl" %} + +{% block title %} +Graph Console +{% endblock %} + +{% block content %} +<nav> + <div class="title"> + <h1>GRAPH CONSOLE (VERSION: {{version}})</h1> + </div> +</nav> +<main class="dashboard"> + <!-- flex: 1 1 0 + min-width: 0: size this .dashboard flex item from the + viewport, never from content — content-driven growth (e.g. the G6 + canvas) would otherwise feed back into column width. --> + <section class="widget" style="max-width: none; flex: 1 1 0; min-width: 0;"> + <p><a href="/dbg">← Back to debug</a></p> + + <div style="display: flex; gap: 16px; align-items: flex-start;"> + <div style="flex: 0 0 350px; min-width: 300px;"> + + <fieldset> + <legend>Load graph from Penpot</legend> + <desc> + Click file or paste UUID to load Penpot file into an in-memory + Ladybug database. Loading a new file replaces the previous one. + </desc> + <div id="graph-files-tree" style="font-size: 13px; margin-bottom: 6px;">Loading…</div> + <form id="graph-load-form" method="post" action="/dbg/actions/graph-load"> + <div class="row" style="display: flex; gap: 8px;"> + <input type="text" style="flex: 1; min-width: 0; font-size: 11px;" name="file-id" + placeholder="file-id" + value="{% if session %}{{session.file-id}}{% endif %}" /> + {% if session %} + <input type="submit" value="Reload" + title="Re-ingests the file in the box from scratch — the recovery fallback when live sync drifted or skipped changes. Paste a different UUID to switch files." /> + <input type="submit" value="Unload" form="graph-unload-form" + title="Drop the in-memory session and free its memory" /> + {% else %} + <input type="submit" value="Load" /> + {% endif %} + </div> + </form> + {% if session %} + <form id="graph-unload-form" method="post" action="/dbg/actions/graph-unload"></form> + {% endif %} + </fieldset> + + {% if session %} + <fieldset> + <legend>Loaded session (<span id="graph-loaded-at">{{session.loaded-at}}</span>)</legend> + <desc> + <p style="margin: 0 0 4px;"> + File: <span id="graph-file-crumbs"></span><b><a id="graph-penpot-link" + data-file-id="{{session.file-id}}" + target="_blank">{{session.name}}</a></b> + <span id="graph-bm" style="color: #666;" + title="resident memory of the session's in-memory DB (buffer manager)"></span><br /> + <span title="The file's revision when it was ingested vs the revision of the last live change applied to the graph. They start equal; a graph value behind the workspace means missed changes — use Reload."> + Revisions: ingested at <b>{{session.revn}}</b> · graph now + <b id="graph-sync-revn">{% if session.graph-revn %}{{session.graph-revn}}{% else %}{{session.revn}}{% endif %}</b> + </span><br /> + Graph size: <b id="graph-size">…</b><br /> + Schema: <b>{{session.schema-version}}</b> + </p> + <p id="graph-sync-status" style="margin: 0;"> + Feed: <b id="graph-ws-status">connecting…</b> + <span id="graph-sync-error" style="display:none; margin-left: 1em; color: #b91c1c;"></span> + </p> + </desc> + </fieldset> + + <fieldset id="graph-changelog-box" style="display: none;"> + <legend>Live changes</legend> + <desc>Latest changes applied to the backend graph.</desc> + <table id="graph-changelog" border="1" cellpadding="4" cellspacing="0" + style="border-collapse: collapse; width: 100%;"> + <thead> + <tr> + <th>revn</th> + <th>op</th> + <th>id</th> + </tr> + </thead> + <tbody id="graph-changelog-body"></tbody> + </table> + </fieldset> + + <fieldset> + <legend>Query graph, read-only (<a href="https://docs.ladybugdb.com/cypher/" + target="_blank">LadybugDB Cypher</a>)</legend> + <form id="graph-query-form" method="post" action="/dbg/actions/graph-query"> + <div class="row"> + <textarea name="query" rows="8" style="width:100%; font-family: monospace;" + data-default-query="{{default-query}}">{{query}}</textarea> + </div> + <div class="row"> + <input type="submit" value="Run query" /> + </div> + </form> + </fieldset> + + <div id="graph-query-output"> + {% if error %} + <fieldset> + <legend>Error</legend> + <pre>{{error}}</pre> + </fieldset> + {% endif %} + + {% if query-result %} + <fieldset> + <legend>Results ({{query-result.row-count}} rows{% if query-result.truncated? %}, truncated{% endif %})</legend> + <div style="max-height: 45vh; overflow: auto;"> + <table border="1" cellpadding="4" cellspacing="0" style="border-collapse: collapse; width: 100%;"> + <thead> + <tr> + {% for column in query-result.columns %} + <th>{{column}}</th> + {% endfor %} + </tr> + </thead> + <tbody> + {% for row in query-result.rows %} + <tr> + {% for cell in row %} + <td><code>{{cell}}</code></td> + {% endfor %} + </tr> + {% endfor %} + </tbody> + </table> + </div> + </fieldset> + {% endif %} + </div> + {% endif %} + + </div><!-- left column --> + + {% if session %} + <div id="graph-view-column" + style="flex: 1 1 auto; min-width: 0;"> + <fieldset id="graph-view-panel"> + <legend>Graph view + <label style="margin-left: 1em; font-size: 12px; font-weight: normal;"> + layout: <select id="graph-layout-select"></select> + </label> + <label style="margin-left: 1em; font-size: 12px; font-weight: normal;" + title="When checked, graphs up to 100 nodes render with entrance animation."> + <input type="checkbox" id="graph-animate-toggle" /> animate + </label> + <label style="margin-left: 1em; font-size: 12px; font-weight: normal;" + title="When set, added/removed marks fade out over this many display steps (0 disables diff marks)."> + fade: <input type="number" id="graph-diff-steps" min="0" max="20" + style="width: 1.8em;" /> steps + </label> + <label style="margin-left: 1em; font-size: 12px; font-weight: normal;" + title="When checked, containers render as foldable boxes (combos); double-click folds/unfolds. When unchecked, plain nodes only — the fold controls to the right go dormant."> + <input type="checkbox" id="graph-fold-toggle" /> foldable containers + </label> + <label style="margin-left: 1em; font-size: 12px; font-weight: normal;" + title="When checked, every container without changed elements collapses, so changes stand out (overrides manual folds)."> + <input type="checkbox" id="graph-diff-fold" /> fold unchanged + </label> + <label style="margin-left: 1em; font-size: 12px; font-weight: normal;" + title="When set, overview mode: 0 expands every container, n ≥ 1 collapses every container at depth ≥ n from the root (overrides manual folds; empty = manual folding)."> + fold ≥ depth: <input type="number" id="graph-depth-fold" min="0" max="99" + style="width: 2.2em;" /> + </label> + </legend> + <desc> + Live view of the in-memory Ladybug graph (AntV G6). Double-click + folds containers when folding is on. + </desc> + <div id="graph-legend" style="font-size: 12px; margin: 4px 0;"></div> + <div id="graph-canvas" + style="width: 100%; height: 600px; border: 1px solid #ccc; background: #fff; overflow: hidden;"></div> + <div id="graph-view-status" style="color: #666; font-size: 12px;"></div> + <div id="graph-node-inspector" + style="display: none; font-size: 12px; margin-top: 4px; border: 1px solid #ccc; + padding: 6px; max-height: 40vh; overflow: auto;"></div> + <button type="button" id="graph-render-anyway" style="display: none;">Render anyway</button> + <button type="button" id="graph-filter-reset" style="display: none;">Show full graph</button> + </fieldset> + </div> + {% endif %} + + </div><!-- flex row --> + </section> +</main> + +<style> + #graph-view-column { + position: sticky; + top: 8px; + } + /* Fieldsets default to min-inline-size: min-content, so the panel could + never shrink below its content and grew with the G6 canvas instead + (content -> fieldset -> column -> canvas feedback). Let it shrink; + the legend wraps and the canvas clips. */ + #graph-view-panel { + min-inline-size: 0; + } + #graph-view-column.graph-view-expanded { + position: fixed; + inset: 0; + z-index: 1000; + background: #fff; + overflow: auto; + padding: 12px; + margin: 0; + } + #graph-view-column.graph-view-expanded #graph-canvas { + height: calc(100vh - 160px) !important; + } + /* Eye-guiding pulse on just-changed elements: DOM overlay rings over the + canvas, independent of the G6 animation gate (big graphs render with + animation off). Two quick beats, then gone. */ + #graph-canvas { position: relative; } + .graph-pulse { + position: absolute; + border: 3px solid; + border-radius: 50%; + pointer-events: none; + animation: graph-pulse 0.6s ease-out 2; + opacity: 0; + } + @keyframes graph-pulse { + 0% { transform: translate(-50%, -50%) scale(0.5); opacity: 0.9; } + 100% { transform: translate(-50%, -50%) scale(2.1); opacity: 0; } + } + #graph-files-tree summary { cursor: pointer; } + #graph-files-tree ul { margin: 2px 0 4px 0; padding-left: 2em; } + #graph-files-tree a { text-decoration: none; } + #graph-files-tree a:hover { text-decoration: underline; } +</style> + +<script> +(function () { + const tree = document.getElementById("graph-files-tree"); + const loadForm = document.getElementById("graph-load-form"); + const loadInput = loadForm ? loadForm.querySelector("input[name=file-id]") : null; + const penpotLink = document.getElementById("graph-penpot-link"); + if (!tree) return; + + // Make the loaded-session file name a link into the Penpot workspace. + // The legacy /#/workspace/<project-id>/<file-id> route resolves the team + // itself, and project-id is already in the files-tree payload; same + // origin as this page, so no base URL to configure. + function linkLoadedFile(teams) { + if (!penpotLink || !penpotLink.dataset.fileId) return; + const fileId = penpotLink.dataset.fileId; + teams.forEach(function (team) { + (team.projects || []).forEach(function (project) { + (project.files || []).forEach(function (file) { + if (file.id === fileId) { + penpotLink.href = "/#/workspace/" + project.id + "/" + file.id; + penpotLink.title = "Open in Penpot"; + const crumbs = document.getElementById("graph-file-crumbs"); + if (crumbs) crumbs.textContent = team.name + " › " + project.name + " › "; + } + }); + }); + }); + } + + function fileLink(file) { + const li = document.createElement("li"); + const a = document.createElement("a"); + a.href = "#"; + a.textContent = file.name; + a.title = file.id; + a.addEventListener("click", function (ev) { + ev.preventDefault(); + if (loadInput) { + loadInput.value = file.id; + loadForm.submit(); + } + }); + li.appendChild(a); + return li; + } + + fetch("/dbg/actions/graph-files") + .then(function (resp) { + if (!resp.ok) throw new Error("graph-files HTTP " + resp.status); + return resp.json(); + }) + .then(function (data) { + tree.textContent = ""; + const teams = data.teams || []; + linkLoadedFile(teams); + if (!teams.length) { + tree.textContent = "No files found."; + return; + } + teams.forEach(function (team) { + const teamEl = document.createElement("details"); + const teamSummary = document.createElement("summary"); + teamSummary.textContent = team.name; + teamEl.appendChild(teamSummary); + (team.projects || []).forEach(function (project) { + const projEl = document.createElement("details"); + projEl.style.marginLeft = "1em"; + const projSummary = document.createElement("summary"); + projSummary.textContent = project.name; + projEl.appendChild(projSummary); + const list = document.createElement("ul"); + (project.files || []).forEach(function (file) { + list.appendChild(fileLink(file)); + }); + projEl.appendChild(list); + teamEl.appendChild(projEl); + }); + tree.appendChild(teamEl); + }); + }) + .catch(function (err) { + tree.textContent = "Failed to load file tree: " + err; + }); +})(); +</script> + +{% if session %} +<script src="https://cdn.jsdelivr.net/npm/@antv/g6@5.1.1/dist/g6.min.js"></script> +<script> +(function () { + const fileId = "{{session.file-id}}"; + const sessionId = crypto.randomUUID(); + const wsScheme = location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = wsScheme + "//" + location.host + + "/ws/notifications?session-id=" + sessionId; + + const wsStatus = document.getElementById("graph-ws-status"); + const syncRevnEl = document.getElementById("graph-sync-revn"); + const syncErrorEl = document.getElementById("graph-sync-error"); + const changelogBox = document.getElementById("graph-changelog-box"); + const changelogBody = document.getElementById("graph-changelog-body"); + + let ws = null; + + // --- G6 graph view ------------------------------------------------- + // Monochrome entity scheme: chroma = change. All entities share one + // slate hue; *lightness* separates within-glyph siblings (validated: + // worst within-glyph pair ΔE 17.5; the chroma floor is deliberately + // violated — saturated color is reserved for diff marks and would + // otherwise compete with them). Glyph class carries type identity, + // direct labels relieve the light-step contrast; SVGRaw is the hollow + // hexagon instead of a fourth lightness step. + const NODE_STYLES = { + "Document": { color: "#14202e", glyph: "diamond", size: 28 }, + "Page": { color: "#2e415a", glyph: "rect", size: 22 }, + "Frame": { color: "#22344a", glyph: "hexagon", size: 18 }, + "Group": { color: "#5b7089", glyph: "hexagon", size: 18 }, + "Boolean": { color: "#93a4b8", glyph: "hexagon", size: 18 }, + "SVGRaw": { color: "#8b98a9", glyph: "hexagon", size: 18, hollow: true }, + "Rectangle": { color: "#93a4b8", glyph: "rect", size: 14 }, + "Circle": { color: "#3b5069", glyph: "circle", size: 14 }, + "Path": { color: "#5b7089", glyph: "triangle", size: 14 }, + "Text": { color: "#8b9cb1", glyph: "circle", size: 14 }, + "Image": { color: "#5b7089", glyph: "star", size: 14 }, + "Component": { color: "#8195ab", glyph: "diamond", size: 20 } + }; + const FALLBACK_STYLE = { color: "#5b7089", glyph: "circle", size: 14 }; + // Edge style keyed on rel (wire field since slice-3); future edge + // attributes can feed styling the same way. All rels stay grey (chroma + // belongs to diff marks); identity comes from `sym`, a compact unicode + // rel label rendered mid-edge (abacus viewer EDGE_SYM convention) — + // dash variants alone cannot carry the growing rel roster. IsChildOf + // is the unlabeled default (the background tree structure). + // `sym` may be a full rel name: no compact glyph reads as "derived from + // a template" (∈ wrongly connotes membership), so IsInstanceOf spells + // itself out; the legend falls back to an arrow for long syms. No + // dashed edges — the label alone carries rel identity. + const EDGE_STYLES = { + "IsChildOf": { stroke: "#b3b0a8" }, + "IsInstanceOf": { stroke: "#8b98a9", sym: "IsInstanceOf" } + }; + const FALLBACK_EDGE_STYLE = { stroke: "#b3b0a8" }; + // --- graph diff: add/remove marks with step fade -------------------- + // A "step" is a display-changing refetch (no-op skips age nothing). + // Added elements get a green halo; removed ones stay in the display as + // ghosts with a dashed crimson halo and fading opacity (dash + fade + // carry the added/removed distinction for red-green CVD; the pair + // validates at deutan ΔE 17.4). Marks fade linearly with age and drop + // after N steps; N comes from the "fade" box (0 disables the feature). + // The diff is vs the previous display step, not between arbitrary + // revisions — true version-to-version diffs await server deltas / the + // graph-based-VCS work. + const DIFF_ADDED_COLOR = "#40c057"; + const DIFF_REMOVED_COLOR = "#c2255c"; + let diffMarks = { nodes: {}, edges: {} }; + + function diffSteps() { + const v = parseInt(localStorage.getItem("graph-diff-steps"), 10); + return Number.isFinite(v) && v >= 0 && v <= 20 ? v : 3; + } + + function diffEdgeKey(source, rel, target) { + return source + "|" + (rel || "IsChildOf") + "|" + target; + } + + function liveMark(marks, key) { + const m = marks[key]; + return m && m.age < diffSteps() ? m : null; + } + + // 1.0 at age 0 down to 1/N at age N-1; the mark drops at age N. + function diffFade(mark) { + return 1 - mark.age / diffSteps(); + } + + function markDiff(prev, next) { + if (!diffSteps() || !prev) { + diffMarks = { nodes: {}, edges: {} }; + return; + } + [diffMarks.nodes, diffMarks.edges].forEach(function (marks) { + Object.keys(marks).forEach(function (k) { + marks[k].age += 1; + if (marks[k].age >= diffSteps()) delete marks[k]; + }); + }); + const prevNodes = {}; + prev.nodes.forEach(function (n) { prevNodes[n.id] = n; }); + const nextNodes = {}; + next.nodes.forEach(function (n) { nextNodes[n.id] = n; }); + next.nodes.forEach(function (n) { + if (!prevNodes[n.id]) diffMarks.nodes[n.id] = { kind: "added", age: 0 }; + }); + prev.nodes.forEach(function (n) { + if (!nextNodes[n.id]) diffMarks.nodes[n.id] = { kind: "removed", age: 0, node: n }; + }); + const prevEdges = {}; + prev.edges.forEach(function (e) { prevEdges[diffEdgeKey(e.source, e.rel, e.target)] = e; }); + const nextEdges = {}; + next.edges.forEach(function (e) { nextEdges[diffEdgeKey(e.source, e.rel, e.target)] = e; }); + Object.keys(nextEdges).forEach(function (k) { + if (!prevEdges[k]) diffMarks.edges[k] = { kind: "added", age: 0 }; + }); + Object.keys(prevEdges).forEach(function (k) { + if (!nextEdges[k]) diffMarks.edges[k] = { kind: "removed", age: 0, edge: prevEdges[k] }; + }); + } + + // Removed elements stay displayed as fading ghosts until their mark + // expires. Ghosts respect the query filter and re-enter layout and + // combo derivation through their ghost IsChildOf edges, so they keep + // their old place in the tree while fading. + function withGhosts(data) { + const nodes = data.nodes.slice(); + const present = {}; + nodes.forEach(function (n) { present[n.id] = true; }); + Object.keys(diffMarks.nodes).forEach(function (id) { + const m = liveMark(diffMarks.nodes, id); + if (m && m.kind === "removed" && m.node && !present[id] + && !hiddenTables.has(m.node.table) + && (!graphFilterIds || graphFilterIds.has(id))) { + nodes.push(m.node); + present[id] = true; + } + }); + const edges = data.edges.slice(); + const have = {}; + edges.forEach(function (e) { have[diffEdgeKey(e.source, e.rel, e.target)] = true; }); + Object.keys(diffMarks.edges).forEach(function (k) { + const m = liveMark(diffMarks.edges, k); + if (m && m.kind === "removed" && m.edge && !have[k] + && present[m.edge.source] && present[m.edge.target]) { + edges.push(m.edge); + } + }); + return { nodes: nodes, edges: edges, revn: data.revn, truncated: data.truncated }; + } + + function nodeMark(id) { + return liveMark(diffMarks.nodes, id); + } + + function edgeMark(d) { + return liveMark(diffMarks.edges, diffEdgeKey(d.source, d.data.rel, d.target)); + } + + function diffColor(m) { + return m.kind === "added" ? DIFF_ADDED_COLOR : DIFF_REMOVED_COLOR; + } + + function anyLiveMarks() { + return Object.keys(diffMarks.nodes).some(function (k) { return liveMark(diffMarks.nodes, k); }) + || Object.keys(diffMarks.edges).some(function (k) { return liveMark(diffMarks.edges, k); }); + } + + // Node ids touched by live marks: marked nodes plus the endpoints of + // marked edges (edge keys are "src|rel|tgt"). + function liveMarkedNodeIds() { + const ids = new Set(); + Object.keys(diffMarks.nodes).forEach(function (id) { + if (liveMark(diffMarks.nodes, id)) ids.add(id); + }); + Object.keys(diffMarks.edges).forEach(function (k) { + if (liveMark(diffMarks.edges, k)) { + const parts = k.split("|"); + ids.add(parts[0]); + ids.add(parts[2]); + } + }); + return ids; + } + + function diffFoldEnabled() { + return localStorage.getItem("graph-diff-fold") === "1"; + } + + // Overview mode: null = off, else collapse containers at depth ≥ value + // (root = depth 0, so e.g. 2 folds the containers hanging from a Page). + function depthFoldValue() { + const raw = localStorage.getItem("graph-depth-fold"); + if (raw == null || raw === "") return null; + const v = parseInt(raw, 10); + return Number.isFinite(v) && v >= 0 ? v : null; + } + + // One-shot pulse rings on age-0 marks, placed ~post-render (positions + // are sampled once; pulses don't track pan/zoom during their ~1.2 s). + function pulseAt(canvasPoint, color, sizePx) { + const host = document.getElementById("graph-canvas"); + if (!host || !g6graph) return; + const vp = g6graph.getViewportByCanvas(canvasPoint); + const el = document.createElement("div"); + el.className = "graph-pulse"; + el.style.borderColor = color; + el.style.left = vp[0] + "px"; + el.style.top = vp[1] + "px"; + el.style.width = sizePx + "px"; + el.style.height = sizePx + "px"; + host.appendChild(el); + setTimeout(function () { el.remove(); }, 1400); + } + + function schedulePulses() { + if (!diffSteps()) return; + setTimeout(function () { + if (!g6graph) return; + let zoom = 1; + try { zoom = g6graph.getZoom() || 1; } catch (_err) {} + Object.keys(diffMarks.nodes).forEach(function (id) { + const m = diffMarks.nodes[id]; + if (!m || m.age !== 0) return; + try { + const p = g6graph.getElementPosition(id); + pulseAt([p[0], p[1]], diffColor(m), + Math.max(18, Math.min(64, 26 * zoom))); + } catch (_err) { /* hidden in a collapsed combo or gone */ } + }); + Object.keys(diffMarks.edges).forEach(function (k) { + const m = diffMarks.edges[k]; + if (!m || m.age !== 0) return; + const parts = k.split("|"); + try { + const a = g6graph.getElementPosition(parts[0]); + const b = g6graph.getElementPosition(parts[2]); + pulseAt([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], diffColor(m), + Math.max(14, Math.min(48, 18 * zoom))); + } catch (_err) { /* endpoint hidden or gone */ } + }); + }, 300); + } + // --- end graph diff -------------------------------------------------- + // Legend glyph characters mirroring the G6 node types above. + const GLYPH_CHARS = { diamond: "◆", rect: "■", hexagon: "⬢", + circle: "●", triangle: "▲", star: "★" }; + // Above this many nodes/edges the view is not rendered automatically; + // the "Render anyway" button forces it (~1.5 s per 2k nodes, measured; + // edges gate the guard too since plugin/combo cost scales with them). + // Escape hatch: /dbg/graph?safe disables auto-render entirely, so a + // page that hung on render can always be re-entered. + const RENDER_GUARD_NODES = 4000; + const RENDER_GUARD_EDGES = 8000; + // G6's entrance/update animation is nice didactics on small graphs but + // the performance killer at scale (>2 min at 1700 nodes vs ~1.5 s off); + // keep it only below this node count. + const ANIMATE_MAX_NODES = 100; + const LAYOUT_XS = 40; // horizontal leaf slot spacing + const LAYOUT_YS = 70; // vertical rank (depth) spacing + + // Layout dropdown source of truth: name -> G6 layout config, or null for + // the built-in O(n) tree layout (preset positions from treePositions, + // fastest, exploits IsChildOf being a tree). The <select> is populated + // from these keys; add/remove/tune entries here. Pruned 2026-07-16: + // grid/random/force/fruchterman/force-atlas2 added nothing over this + // set. combo-combined is the only combo-aware layout: it lays out each + // combo's members internally, then treats every combo as one super-node + // in an outer force pass. Non-hierarchical layouts carry overlap + // parameters (preventOverlap ignores label extents — see + // DENSE_LABEL_LAYOUTS). + const LAYOUTS = { + "tree": null, + "antv-dagre": { type: "antv-dagre", rankdir: "BT", nodesep: 10, ranksep: 40, sortByCombo: true }, + "dagre": { type: "dagre", rankdir: "BT" }, + "circular": { type: "circular" }, + "concentric": { type: "concentric", preventOverlap: true, nodeSize: 32, nodeSpacing: 12 }, + "radial": { type: "radial", preventOverlap: true, nodeSize: 32, unitRadius: 90 }, + "d3-force": { type: "d3-force", collide: { radius: 26 } }, + "combo-combined": { type: "combo-combined" } + }; + // Tried and rejected 2026-07-17: fishbone (renders nothing on graph + // data — it is a category layout) and compact-box (degenerates into an + // overlapped chain: G6 tree layouts traverse parent→child while our + // IsChildOf edges point child→parent; revisit with a reversed-edge feed + // if a compact tree view is wanted). + const DEFAULT_LAYOUT = "tree"; + // Always-on labels are the residual overlap driver on ring/stress + // layouts (their collision handling ignores label extents), so these + // get smaller labels; the node inspector carries full identity. + const DENSE_LABEL_LAYOUTS = { + "concentric": true, "radial": true, "d3-force": true + }; + + const graphViewStatus = document.getElementById("graph-view-status"); + let g6graph = null; + let g6graphLayout = null; + let g6graphAnimated = null; + let refetchTimer = null; + let lastGraphData = null; + let lastGraphSig = null; + let renderForced = false; + let graphFilterIds = null; + // The query that produced the filter, kept so the filter can follow the + // graph. A filter is a set of node ids, and live sync creates ids the set + // has never seen, so a frozen set hides every node created after the query + // ran. Re-running the query on each repaint is what keeps a filtered view + // honest; the cadence is the repaint's, and a repaint is skipped when the + // projection is unchanged. + let graphFilterQuery = null; + // Node tables hidden via legend clicks (session-local, not persisted). + const hiddenTables = new Set(); + + function currentLayoutName() { + const stored = localStorage.getItem("graph-layout"); + return Object.prototype.hasOwnProperty.call(LAYOUTS, stored) ? stored : DEFAULT_LAYOUT; + } + + function nodeStyle(table) { + return NODE_STYLES[table] || FALLBACK_STYLE; + } + + function edgeStyle(rel) { + return EDGE_STYLES[rel] || FALLBACK_EDGE_STYLE; + } + + function comboIdFor(nodeId) { + return "combo:" + nodeId; + } + + function foldEnabled() { + return localStorage.getItem("graph-fold-containers") !== "0"; + } + + // Off ⇒ animation disabled unconditionally, incl. small graphs (the + // adaptive ≤ ANIMATE_MAX_NODES rule only applies when this is on). + function animateEnabled() { + return localStorage.getItem("graph-animate") !== "0"; + } + + // IsChildOf is a tree, so an O(n) tidy layout replaces a generic DAG + // layout: depth = rank (y), post-order leaf slots = x, parents centered + // over their children. antv-dagre needed ~7 s at 1700 nodes; this is free. + function treePositions(nodes, parentOf, childrenOf) { + const pos = {}; + let slot = 0; + function place(id, depth) { + const kids = childrenOf[id] || []; + if (!kids.length) { + pos[id] = { x: slot * LAYOUT_XS, y: depth * LAYOUT_YS }; + slot += 1; + return; + } + kids.forEach(function (k) { place(k, depth + 1); }); + const xs = kids.map(function (k) { return pos[k].x; }); + pos[id] = { x: (Math.min.apply(null, xs) + Math.max.apply(null, xs)) / 2, + y: depth * LAYOUT_YS }; + slot += 1; // breathing room between adjacent subtrees + } + nodes.forEach(function (n) { + if (!parentOf[n.id]) place(n.id, 0); + }); + return pos; + } + + function toG6Data(data, collapsedIds, withCombos, presetPositions, diffFold) { + const nodes = data.nodes || []; + const edges = (data.edges || []) + .slice() + .sort(function (a, b) { return (a.position || 0) - (b.position || 0); }); + // Hierarchy (tree ranking, combo derivation, fold-ability) comes from + // IsChildOf only — still a tree by construction. Other rels + // (IsInstanceOf, …) are overlay edges drawn between positioned nodes. + const parentOf = {}; + const childrenOf = {}; + edges.forEach(function (e) { + if ((e.rel || "IsChildOf") !== "IsChildOf") return; + parentOf[e.source] = e.target; + (childrenOf[e.target] = childrenOf[e.target] || []).push(e.source); + }); + // Preset positions only for the built-in tree layout; under a G6 + // layout they would make animated renders flash tree-then-layout. + const pos = presetPositions ? treePositions(nodes, parentOf, childrenOf) : null; + // Foldable = has children and has a parent: the IsChildOf root of the + // loaded graph (today a Document, later maybe a Project or Team) is + // never a combo — folding the whole graph is useless. Empty containers + // stay plain nodes. + const hasCombo = {}; + if (withCombos) { + nodes.forEach(function (n) { + if ((childrenOf[n.id] || []).length && parentOf[n.id]) hasCombo[n.id] = true; + }); + } + // Derived fold state (overrides manual double-click folds while on): + // overview mode collapses every combo at depth ≥ the limit — except + // 0, which expands everything (saves hunting for the max depth) — + // and all of them when only diff-fold is on; diff-fold then re-opens + // the ancestor paths of changed elements — combined, that reads as + // "overview, with changes drilled open". + const depthLimit = depthFoldValue(); + if (withCombos && (diffFold || depthLimit != null)) { + const depthOf = {}; + const depth = function (id) { + if (depthOf[id] != null) return depthOf[id]; + const p = parentOf[id]; + depthOf[id] = p ? depth(p) + 1 : 0; + return depthOf[id]; + }; + collapsedIds = new Set(); + Object.keys(hasCombo).forEach(function (id) { + if (depthLimit === 0) return; + if (depthLimit == null || depth(id) >= depthLimit) { + collapsedIds.add(comboIdFor(id)); + } + }); + if (diffFold) { + liveMarkedNodeIds().forEach(function (id) { + if (hasCombo[id]) collapsedIds.delete(comboIdFor(id)); + let p = parentOf[id]; + while (p) { + if (hasCombo[p]) collapsedIds.delete(comboIdFor(p)); + p = parentOf[p]; + } + }); + } + } + const combos = []; + nodes.forEach(function (n) { + if (!hasCombo[n.id]) return; + const combo = { id: comboIdFor(n.id), data: { label: n.label, table: n.table } }; + const p = parentOf[n.id]; + if (p && hasCombo[p]) combo.combo = comboIdFor(p); + // Explicit boolean both ways: setData merges datum props by id on a + // live instance, so omitting `collapsed` would retain a previous + // `true` — a change inside a folded combo could then never expand it + // (the fold-unchanged bug). + combo.style = { collapsed: !!(collapsedIds && collapsedIds.has(combo.id)) }; + combos.push(combo); + }); + const g6nodes = nodes.map(function (n) { + const out = { id: n.id, + data: { label: n.label, table: n.table } }; + if (pos) { + const p = pos[n.id] || { x: 0, y: 0 }; + out.style = { x: p.x, y: p.y }; + } + if (hasCombo[n.id]) { + out.combo = comboIdFor(n.id); + } else if (parentOf[n.id] && hasCombo[parentOf[n.id]]) { + out.combo = comboIdFor(parentOf[n.id]); + } + return out; + }); + const g6edges = edges.map(function (e) { + return { source: e.source, target: e.target, + data: { position: e.position, rel: e.rel || "IsChildOf" } }; + }); + return { nodes: g6nodes, edges: g6edges, combos: combos }; + } + + function collapsedComboIds() { + if (!g6graph) return new Set(); + try { + return new Set(g6graph.getComboData() + .filter(function (c) { return c.style && c.style.collapsed; }) + .map(function (c) { return c.id; })); + } catch (_err) { + return new Set(); + } + } + + function renderGraph(g6data) { + const layoutName = currentLayoutName(); + const animate = animateEnabled() && g6data.nodes.length <= ANIMATE_MAX_NODES; + if (g6graph && (g6graphLayout !== layoutName || g6graphAnimated !== animate)) { + // Layout or animation-mode switch: recreate the graph (cheap); + // neither is swappable on a live instance. + try { g6graph.destroy(); } catch (_err) {} + g6graph = null; + } + if (g6graph) { + g6graph.setData(g6data); + return g6graph.render().catch(function (_err) { + /* instance may be destroyed mid-render on rapid toggle/layout switches */ + }); + } + g6graphLayout = layoutName; + g6graphAnimated = animate; + const layoutCfg = LAYOUTS[layoutName]; + const opts = { + container: "graph-canvas", + data: g6data, + autoFit: "view", + animation: animate, + padding: 20, + node: { + type: function (d) { return nodeStyle(d.data.table).glyph; }, + style: { + size: function (d) { return nodeStyle(d.data.table).size; }, + fill: function (d) { + const s = nodeStyle(d.data.table); + return s.hollow ? "#ffffff" : s.color; + }, + // Unmarked nodes carry no stroke (hollow ones keep a thin one as + // their identity); the stroke channel belongs to diff marks: + // thick colored ring, dashed for removals (the CVD-safe cue). + stroke: function (d) { + const m = nodeMark(d.id); + return m ? diffColor(m) : nodeStyle(d.data.table).color; + }, + lineWidth: function (d) { + if (nodeMark(d.id)) return 2.5; + return nodeStyle(d.data.table).hollow ? 1.5 : 0; + }, + lineDash: function (d) { + const m = nodeMark(d.id); + return m && m.kind === "removed" ? [3, 3] : 0; + }, + labelText: function (d) { return d.data.label; }, + labelFontSize: function () { + return DENSE_LABEL_LAYOUTS[currentLayoutName()] ? 7 : 9; + }, + labelFill: "#0b0b0b", + labelPlacement: "bottom", + halo: function (d) { return !!nodeMark(d.id); }, + haloStroke: function (d) { + const m = nodeMark(d.id); + return m ? diffColor(m) : "#ffffff"; + }, + haloLineWidth: 12, + haloStrokeOpacity: function (d) { + const m = nodeMark(d.id); + return m ? 0.35 * diffFade(m) : 0; + }, + opacity: function (d) { + const m = nodeMark(d.id); + return m && m.kind === "removed" ? diffFade(m) : 1; + }, + labelOpacity: function (d) { + const m = nodeMark(d.id); + return m && m.kind === "removed" ? diffFade(m) : 1; + } + }, + state: { + selected: { stroke: "#0b0b0b", lineWidth: 2 }, + active: {}, + inactive: { opacity: 0.2, labelOpacity: 0.2 } + } + }, + edge: { + style: { + stroke: function (d) { + const m = edgeMark(d); + return m ? diffColor(m) : edgeStyle(d.data.rel).stroke; + }, + lineDash: function (d) { return edgeStyle(d.data.rel).lineDash; }, + lineWidth: function (d) { + const m = edgeMark(d); + return m ? (m.kind === "removed" ? 2.5 : 2) : 1; + }, + strokeOpacity: function (d) { + const m = edgeMark(d); + return m ? Math.max(diffFade(m), 0.15) : 1; + }, + labelText: function (d) { return edgeStyle(d.data.rel).sym || ""; }, + labelFontSize: 7, + labelFill: "#52514e", + labelBackground: true, + labelBackgroundFill: "#ffffff", + labelBackgroundOpacity: 0.75, + endArrow: true, + endArrowSize: 6 + }, + state: { + selected: { lineWidth: 2 }, + active: {}, + inactive: { strokeOpacity: 0.1, labelOpacity: 0.1 } + } + }, + combo: { + type: "rect", + style: { + labelText: function (d) { return d.data.label; }, + labelFontSize: 9, + labelFill: "#52514e", + labelPlacement: "top", + stroke: function (d) { return nodeStyle(d.data.table).color; }, + lineWidth: 1, + fillOpacity: 0.03, + radius: 4 + } + }, + behaviors: ["zoom-canvas", "drag-canvas", "drag-element", "collapse-expand", + // Click highlights the 1-degree neighborhood (selected node black + // ring, neighbors keep full strength, the rest dims); clicking + // empty canvas clears. Runs alongside the node:click inspector. + { type: "click-select", degree: 1, + state: "selected", neighborState: "active", unselectedState: "inactive" }], + plugins: [{ + key: "toolbar", + type: "toolbar", + position: "top-left", + getItems: function () { + return [ + { id: "auto-fit", value: "auto-fit" }, + { id: "request-fullscreen", value: "expand" }, + { id: "exit-fullscreen", value: "restore" }, + { id: "export", value: "export-png" } + ]; + }, + onClick: function (value) { + if (value === "auto-fit") { + if (g6graph) g6graph.fitView(); + } else if (value === "expand") { + setExpanded(true); + } else if (value === "restore") { + setExpanded(false); + } else if (value === "export-png") { + exportGraphPng(); + } + } + }] + }; + if (layoutCfg) opts.layout = layoutCfg; + g6graph = new G6.Graph(opts); + // Re-attached on every instance creation (layout/animation switches + // destroy and recreate the graph). + g6graph.on("node:click", function (e) { + const id = e.target && e.target.id; + if (id) showNodeInspector(String(id)); + }); + return g6graph.render().catch(function (_err) { + /* see above */ + }); + } + + // Hand-rolled on purpose: the G6 legend plugin substitutes its own + // marker set (hexagon→circle, star→cross), breaking glyph identity. + // Entries reflect the *displayed* data only: node tables in roster + // order (unknown tables appended with the fallback style), then rels. + // Clean graph-only capture (no page chrome): the whole laid-out graph + // regardless of viewport, downloaded as PNG. Also the fast path for + // agents debugging the console — no full-page screenshot needed. + function exportGraphPng() { + if (!g6graph) return; + g6graph.toDataURL({ mode: "overall" }) + .then(function (dataUrl) { + const a = document.createElement("a"); + a.href = dataUrl; + a.download = "graph-" + (lastGraphData ? lastGraphData.revn : "view") + ".png"; + a.click(); + }) + .catch(function (err) { + graphViewStatus.textContent = "export error: " + err; + }); + } + + // Legend entries are clickable: toggling one hides/shows that table's + // nodes (struck-through while hidden; hidden tables stay listed so they + // can be re-enabled). + function renderGraphLegend(data) { + const el = document.getElementById("graph-legend"); + if (!el) return; + const tables = {}; + (data.nodes || []).forEach(function (n) { tables[n.table] = true; }); + if (lastGraphData) { + lastGraphData.nodes.forEach(function (n) { + if (hiddenTables.has(n.table)) tables[n.table] = true; + }); + } + const rels = {}; + (data.edges || []).forEach(function (e) { rels[e.rel || "IsChildOf"] = true; }); + function nodeItem(table, s) { + const off = hiddenTables.has(table); + return '<span data-table="' + escapeHtml(table) + '"' + + ' title="click to ' + (off ? "show" : "hide") + " " + escapeHtml(table) + ' nodes"' + + ' style="margin-right: 1em; white-space: nowrap; cursor: pointer;' + + (off ? " opacity: 0.35; text-decoration: line-through;" : "") + '">' + + '<span style="color: ' + s.color + '; font-size: 14px;">' + + (s.hollow ? "⬡" : (GLYPH_CHARS[s.glyph] || "●")) + '</span> ' + + escapeHtml(table) + "</span>"; + } + const items = Object.keys(NODE_STYLES) + .filter(function (t) { return tables[t]; }) + .map(function (t) { return nodeItem(t, NODE_STYLES[t]); }); + Object.keys(tables).sort().forEach(function (t) { + if (!NODE_STYLES[t]) items.push(nodeItem(t, FALLBACK_STYLE)); + }); + Object.keys(rels).sort().forEach(function (rel) { + const s = edgeStyle(rel); + const glyph = (s.sym && s.sym.length <= 2) ? s.sym : (s.lineDash ? "⇢" : "→"); + items.push('<span style="margin-right: 1em; white-space: nowrap;">' + + '<span style="color: ' + s.stroke + '; font-size: 14px;">' + + escapeHtml(glyph) + '</span> ' + + escapeHtml(rel) + "</span>"); + }); + if (anyLiveMarks()) { + items.push('<span style="margin-right: 1em; white-space: nowrap;">' + + '<span style="color: ' + DIFF_ADDED_COLOR + '; font-size: 14px;">+</span>' + + ' added</span>'); + items.push('<span style="white-space: nowrap;">' + + '<span style="color: ' + DIFF_REMOVED_COLOR + '; font-size: 14px;">−</span>' + + ' removed</span>'); + } + // join with a space: the spans are nowrap, so the separator is the + // only soft-wrap opportunity in the legend row + el.innerHTML = items.join(" "); + } + + function graphStatusText(data) { + return data.nodes.length + " nodes, " + data.edges.length + " edges" + + " (graph revn " + data.revn + ")" + + (data.truncated ? " — WARNING: truncated at row cap, view is partial" : ""); + } + + // Query filter: induced subgraph of the already-exported graph, selected + // by node ids found in the last Cypher result. No reconstruction from the + // query result is needed — ids are enough to slice the cached export. + function filteredGraphData() { + if (!graphFilterIds && !hiddenTables.size) return lastGraphData; + const keep = {}; + const nodes = lastGraphData.nodes.filter(function (n) { + if (hiddenTables.has(n.table)) return false; + if (graphFilterIds && !graphFilterIds.has(n.id)) return false; + keep[n.id] = true; + return true; + }); + const edges = lastGraphData.edges.filter(function (e) { + return keep[e.source] && keep[e.target]; + }); + return { nodes: nodes, edges: edges, + revn: lastGraphData.revn, truncated: lastGraphData.truncated }; + } + + const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi; + + function idsInResult(result) { + const ids = new Set(); + (result.rows || []).forEach(function (row) { + row.forEach(function (cell) { + const found = String(cell).match(UUID_RE); + if (found) found.forEach(function (m) { ids.add(m.toLowerCase()); }); + }); + }); + return ids; + } + + function presentIds(ids) { + const present = new Set(); + lastGraphData.nodes.forEach(function (n) { if (ids.has(n.id)) present.add(n.id); }); + return present; + } + + function applyQueryFilter(ids, query) { + if (!lastGraphData) return; + const present = presentIds(ids); + if (!present.size) { + graphViewStatus.textContent = "query result matches no nodes in the loaded graph"; + return; + } + graphFilterIds = present; + graphFilterQuery = query || null; + renderCurrent(); + } + + function renderCurrentRefiltered() { + if (!graphFilterQuery) { + renderCurrent(); + return; + } + // A stale filter is worse than a slow one: it hides the very node the + // change created. Re-run, and on failure keep the ids in hand and say so + // rather than pass a filtered-out graph off as current. + runConsoleQuery(graphFilterQuery) + .then(function (data) { + if (data && data.error) throw new Error(data.error); + const result = data && data["query-result"]; + if (result) graphFilterIds = presentIds(idsInResult(result)); + renderCurrent(); + }) + .catch(function (err) { + renderCurrent(); + graphViewStatus.textContent += + " — filter query failed, ids are from its last run: " + err; + }); + } + + function renderCurrent() { + if (!lastGraphData) return; + const data = filteredGraphData(); + const shown = withGhosts(data); + renderGraphLegend(shown); + const anywayBtn = document.getElementById("graph-render-anyway"); + const filterBtn = document.getElementById("graph-filter-reset"); + if (filterBtn) filterBtn.style.display = graphFilterIds ? "inline" : "none"; + const filterNote = graphFilterIds + ? " — query filter: " + data.nodes.length + " of " + + lastGraphData.nodes.length + " nodes" + : ""; + const safeMode = new URLSearchParams(location.search).has("safe"); + if ((safeMode + || shown.nodes.length > RENDER_GUARD_NODES + || shown.edges.length > RENDER_GUARD_EDGES) && !renderForced) { + graphViewStatus.textContent = + graphStatusText(data) + filterNote + + (safeMode ? " — safe mode (?safe): auto-render off" + : " — too large to render automatically"); + if (anywayBtn) anywayBtn.style.display = "inline"; + return; + } + if (anywayBtn) anywayBtn.style.display = "none"; + graphViewStatus.textContent = graphStatusText(data) + filterNote; + // "fold containers" is the master gate for combo rendering; the + // derived fold rules (fold-unchanged, depth) are dormant without it. + renderGraph(toG6Data(shown, collapsedComboIds(), foldEnabled(), + LAYOUTS[currentLayoutName()] == null, diffFoldEnabled())); + } + + function refetchGraph() { + if (typeof G6 === "undefined") { + graphViewStatus.textContent = "G6 failed to load (CDN unreachable)"; + return; + } + fetch("/dbg/actions/graph-data") + .then(function (resp) { + if (resp.status === 404) { + throw new Error("no graph session (backend restarted?) — reload a file"); + } + if (!resp.ok) throw new Error("graph-data HTTP " + resp.status); + return resp.json(); + }) + .then(function (data) { + // Session-fieldset size line stays live even through skipped + // repaints (unlike the status line under the canvas); hover + // shows per-table counts. + // bm-bytes = the session DB's buffer-manager usage (CALL + // bm_info()), i.e. actual resident memory; shown next to the + // file name. Floor-dominated: the wide slice-3 schema costs + // ~115 MiB before any data. + const bmEl = document.getElementById("graph-bm"); + if (bmEl && data["bm-bytes"]) { + bmEl.textContent = "(" + (data["bm-bytes"] / 1048576).toFixed(1) + " MiB)"; + } + const sizeEl = document.getElementById("graph-size"); + if (sizeEl) { + sizeEl.textContent = data.nodes.length + " nodes, " + + data.edges.length + " edges"; + const counts = {}; + data.nodes.forEach(function (n) { + counts[n.table] = (counts[n.table] || 0) + 1; + }); + sizeEl.title = Object.keys(counts).sort().map(function (t) { + return t + "=" + counts[t]; + }).join(", "); + } + // Skip the repaint when the display projection is unchanged: a + // change burst that only touches non-projected attrs (e.g. moving + // shapes around) bumps revn but not the picture. + const sig = JSON.stringify([data.nodes, data.edges, data.truncated]); + if (sig === lastGraphSig) { + lastGraphData = data; + return; + } + markDiff(lastGraphData, data); + lastGraphData = data; + lastGraphSig = sig; + renderCurrentRefiltered(); + schedulePulses(); + }) + .catch(function (err) { + graphViewStatus.textContent = "graph view error: " + err; + }); + } + + // --- node inspector -------------------------------------------------- + // Click a node → its full attribute row from the graph DB, rendered in + // a panel under the canvas. Panel over tooltip: the projected tables + // carry ~80 columns — too much for a tooltip, and the panel persists + // for side-by-side reading without obstructing the graph. + function runConsoleQuery(query) { + const formData = new FormData(); + formData.append("query", query); + return fetch("/dbg/actions/graph-query", { + method: "POST", + headers: { "Accept": "application/json" }, + body: formData + }) + .then(function (resp) { return resp.text(); }) + .then(function (text) { return parseTransitMap(JSON.parse(text)); }); + } + + function showNodeInspector(id) { + const panel = document.getElementById("graph-node-inspector"); + if (!panel || !g6graph) return; + let datum = null; + try { datum = g6graph.getNodeData(id); } catch (_err) { return; } + const table = datum && datum.data && datum.data.table; + const label = (datum && datum.data && datum.data.label) || ""; + // Both values are interpolated into Cypher — accept only what our own + // export produces (bare table names, UUID ids). + if (!table || !/^[A-Za-z]+$/.test(table)) return; + if (!/^[0-9a-f-]{36}$/i.test(id)) return; + panel.style.display = "block"; + panel.textContent = "loading " + id + "…"; + runConsoleQuery("MATCH (n:`" + table + "` {id: uuid('" + id + "')}) RETURN n.*;") + .then(function (data) { + if (data.error) { + panel.textContent = "inspector error: " + data.error; + return; + } + const result = data["query-result"]; + const rows = (result && result.rows) || []; + let html = '<div style="display: flex; justify-content: space-between;">' + + "<b>" + escapeHtml(table) + " " + escapeHtml(label) + "</b>" + + '<button type="button" id="graph-inspector-close">close</button></div>' + + '<div style="color: #666;">' + escapeHtml(id) + "</div>"; + if (!rows.length) { + html += "<div>not in the graph DB (removed?)</div>"; + } else { + const columns = result.columns || []; + let hidden = 0; + // Two-column flow; JSON-like values fold behind the same + // disclosure triangle the file tree uses. + html += '<div style="column-count: 2; column-gap: 16px; margin-top: 4px;">'; + columns.forEach(function (col, i) { + const val = rows[0][i]; + // Ladybug's value->clj fallback renders SQL nulls as "NULL". + if (val === null || val === undefined || val === "" || val === "NULL") { + hidden += 1; + return; + } + const key = escapeHtml(col.replace(/^n\./, "")); + const sval = String(val); + // Fold structured values ({…}) and walls of text (long arrays + // like migrations) behind a disclosure triangle. + if (sval.indexOf("{") !== -1 || sval.length > 120) { + html += '<details style="break-inside: avoid;">' + + '<summary style="cursor: pointer;"><code>' + key + '</code></summary>' + + '<pre style="white-space: pre-wrap; word-break: break-all; margin: 2px 0 4px 1.2em;">' + + escapeHtml(sval) + "</pre></details>"; + } else { + html += '<div style="break-inside: avoid;"><code>' + key + + '</code>: <code>' + escapeHtml(sval) + "</code></div>"; + } + }); + html += "</div>"; + if (hidden) { + html += '<div style="color: #666;">' + hidden + " empty attrs hidden</div>"; + } + } + panel.innerHTML = html; + document.getElementById("graph-inspector-close") + .addEventListener("click", function () { panel.style.display = "none"; }); + }) + .catch(function (err) { + panel.textContent = "inspector error: " + err; + }); + } + // --- end node inspector ---------------------------------------------- + + function scheduleGraphRefetch() { + if (refetchTimer) clearTimeout(refetchTimer); + refetchTimer = setTimeout(function () { + refetchTimer = null; + refetchGraph(); + }, 400); + } + // --- end G6 graph view --------------------------------------------- + + function encodeTransitUuid(uuid) { + return "~u" + uuid; + } + + function encodeSubscribe(fileId) { + return JSON.stringify({ + "~:type": "~:subscribe-file", + "~:file-id": encodeTransitUuid(fileId) + }); + } + + function encodeUnsubscribe(fileId) { + return JSON.stringify({ + "~:type": "~:unsubscribe-file", + "~:file-id": encodeTransitUuid(fileId) + }); + } + + function parseTransitValue(value) { + if (typeof value === "string") { + if (value.startsWith("~:")) return value.slice(2); + if (value.startsWith("~u")) return value.slice(2); + } + if (Array.isArray(value)) return value.map(parseTransitValue); + if (value && typeof value === "object") return parseTransitMap(value); + return value; + } + + function parseTransitMap(obj) { + const out = {}; + for (const [key, value] of Object.entries(obj)) { + const name = key.startsWith("~:") ? key.slice(2) : key; + out[name] = parseTransitValue(value); + } + return out; + } + + function changeDetail(change) { + const parts = []; + if (change.obj && change.obj.type) parts.push("shape=" + change.obj.type); + if (change.operations && change.operations.length) { + const attrs = change.operations + .map(function (op) { return op.attr; }) + .filter(Boolean); + if (attrs.length) parts.push("attrs=" + attrs.join(",")); + } + return parts.join(" "); + } + + function summarizeSkipped(skipped) { + if (!skipped) return ""; + const items = Array.isArray(skipped) ? skipped : [skipped]; + return items.map(function (item) { + if (!item || typeof item !== "object") return String(item); + const type = item.type || "unknown"; + const reason = item.reason ? " (" + item.reason + ")" : ""; + return String(type) + reason; + }).join("; "); + } + + function refreshSyncStatus() { + fetch("/dbg/actions/graph-sync-status") + .then(function (resp) { return resp.text(); }) + .then(function (text) { + const status = parseTransitMap(JSON.parse(text)); + if (status["graph-revn"] !== undefined) { + syncRevnEl.textContent = String(status["graph-revn"]); + } + if (status.sync && status.sync.error) { + syncErrorEl.style.display = "inline"; + syncErrorEl.textContent = "sync error: " + status.sync.error; + } else if (status.sync && status.sync["last-skipped"] + && summarizeSkipped(status.sync["last-skipped"])) { + syncErrorEl.style.display = "inline"; + syncErrorEl.textContent = + "some changes skipped: " + + summarizeSkipped(status.sync["last-skipped"]) + + " (use full reload if needed)"; + } else { + syncErrorEl.style.display = "none"; + syncErrorEl.textContent = ""; + } + }) + .catch(function () {}); + } + + // One row per operation (revn repeats across a batch); op wears the + // canvas diff colors, id shows the uuid's last group with the full + // uuid on hover, or N/A for ops without a subject id. + function appendChanges(revn, changes) { + // The whole box stays hidden until the first change arrives; feed + // state lives in the session fieldset. + changelogBox.style.display = "block"; + let lastRow = null; + (changes && changes.length ? changes : [{}]).forEach(function (change) { + const row = document.createElement("tr"); + const revnCell = document.createElement("td"); + revnCell.textContent = String(revn); + const opCell = document.createElement("td"); + const op = change.type || "?"; + opCell.textContent = op; + const detail = changeDetail(change); + if (detail) opCell.title = detail; + if (op === "add-obj") opCell.style.color = DIFF_ADDED_COLOR; + if (op === "del-obj") opCell.style.color = DIFF_REMOVED_COLOR; + const idCell = document.createElement("td"); + const id = change.id ? String(change.id) : null; + const groups = id ? id.split("-") : null; + idCell.textContent = groups ? groups[groups.length - 1] : "N/A"; + if (id) idCell.title = id; + row.appendChild(revnCell); + row.appendChild(opCell); + row.appendChild(idCell); + changelogBody.appendChild(row); + lastRow = row; + }); + if (lastRow) lastRow.scrollIntoView({ block: "nearest" }); + } + + function handleMessage(raw) { + let msg; + try { + msg = parseTransitMap(JSON.parse(raw)); + } catch (_err) { + return; + } + + if (msg.type !== "file-change" || msg["file-id"] !== fileId) return; + + appendChanges(msg.revn, msg.changes); + setTimeout(refreshSyncStatus, 150); + scheduleGraphRefetch(); + } + + function subscribe() { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(encodeSubscribe(fileId)); + } + } + + let wsClosing = false; + let wsReconnectTimer = null; + + function scheduleReconnect() { + if (wsClosing || wsReconnectTimer) return; + wsReconnectTimer = setTimeout(function () { + wsReconnectTimer = null; + connect(); + }, 3000); + } + + function connect() { + ws = new WebSocket(wsUrl); + wsStatus.textContent = "connecting…"; + + ws.addEventListener("open", function () { + wsStatus.textContent = "subscribed"; + subscribe(); + // catch up on anything missed while disconnected + refreshSyncStatus(); + scheduleGraphRefetch(); + }); + + ws.addEventListener("message", function (event) { + handleMessage(event.data); + }); + + ws.addEventListener("close", function () { + wsStatus.textContent = wsClosing ? "disconnected" : "disconnected — retrying…"; + scheduleReconnect(); + }); + + ws.addEventListener("error", function () { + wsStatus.textContent = "error"; + }); + } + + function escapeHtml(text) { + return String(text) + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """); + } + + function renderQueryOutput(data) { + const output = document.getElementById("graph-query-output"); + if (!output) return; + + if (data.error) { + output.innerHTML = + "<fieldset><legend>Error</legend>" + + "<pre>" + escapeHtml(data.error) + "</pre></fieldset>"; + return; + } + + const result = data["query-result"]; + if (!result) { + output.innerHTML = ""; + return; + } + + const truncated = result["truncated?"] ? ", truncated" : ""; + // Actions bar sits above the table so a long result cannot push the + // "Show result in graph view" button out of sight; the table itself + // scrolls inside a capped container. + let html = + "<fieldset><legend>Results (" + + escapeHtml(String(result["row-count"])) + + " rows" + truncated + ")</legend>" + + "<div id=\"graph-result-actions\" style=\"margin-bottom: 4px;\"></div>" + + "<div style=\"max-height: 45vh; overflow: auto;\">" + + "<table border=\"1\" cellpadding=\"4\" cellspacing=\"0\"" + + " style=\"border-collapse: collapse; width: 100%;\">" + + "<thead><tr>"; + + // filter_* columns feed node ids to the graph-view filter below but + // are hidden from the table (convention; see default query). + const columns = result.columns || []; + const visibleIdx = []; + columns.forEach(function (column, i) { + if (!/^filter_/.test(column)) visibleIdx.push(i); + }); + visibleIdx.forEach(function (i) { + html += "<th>" + escapeHtml(columns[i]) + "</th>"; + }); + html += "</tr></thead><tbody>"; + + (result.rows || []).forEach(function (row) { + html += "<tr>"; + visibleIdx.forEach(function (i) { + html += "<td><code>" + escapeHtml(row[i]) + "</code></td>"; + }); + html += "</tr>"; + }); + + html += "</tbody></table></div></fieldset>"; + output.innerHTML = html; + + // Offer to view the result as an induced subgraph: any UUID appearing + // in any result cell selects that node in the loaded graph. The query + // goes with the ids, so the view can re-run it and follow the graph. + const ids = idsInResult(result); + if (ids.size) { + const queryEl = document.querySelector("#graph-query-form textarea[name=query]"); + const query = queryEl ? queryEl.value : null; + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = "Show result in graph view (" + ids.size + " ids)"; + btn.addEventListener("click", function () { applyQueryFilter(ids, query); }); + document.getElementById("graph-result-actions").appendChild(btn); + } + } + + const queryForm = document.getElementById("graph-query-form"); + if (queryForm) { + // Keep the query text across page reloads (load/unload/full-reload all + // re-render the page with the default query). Only restore over the + // default, never over a server-rendered non-default query. + const queryInput = queryForm.querySelector("textarea[name=query]"); + const defaultQuery = (queryInput.dataset.defaultQuery || "").trim(); + const storedQuery = localStorage.getItem("graph-query"); + if (storedQuery && storedQuery.trim() !== defaultQuery + && queryInput.value.trim() === defaultQuery) { + queryInput.value = storedQuery; + } + queryInput.addEventListener("input", function () { + localStorage.setItem("graph-query", queryInput.value); + }); + queryForm.addEventListener("submit", function (event) { + event.preventDefault(); + const formData = new FormData(queryForm); + fetch("/dbg/actions/graph-query", { + method: "POST", + headers: { "Accept": "application/json" }, + body: formData + }) + .then(function (resp) { return resp.text(); }) + .then(function (text) { + renderQueryOutput(parseTransitMap(JSON.parse(text))); + }) + .catch(function (err) { + renderQueryOutput({ error: String(err) }); + }); + }); + } + + // In-page expand (no Fullscreen API): keeps browser chrome and window + // manager splits usable while the graph takes the whole page. Entered + // via the toolbar's expand icon; exited via its exit icon or Esc. + const graphColumn = document.getElementById("graph-view-column"); + + function resizeGraphSoon() { + setTimeout(function () { + if (g6graph) { + const canvas = document.getElementById("graph-canvas"); + try { + g6graph.setSize(canvas.clientWidth, canvas.clientHeight); + g6graph.fitView(); + } catch (_err) { /* autoResize covers most cases */ } + } + }, 120); + } + + function setExpanded(expanded) { + graphColumn.classList.toggle("graph-view-expanded", expanded); + resizeGraphSoon(); + } + + // Follow container size (G6's autoResize is inert on this build). + // Safe only because the container's width is viewport-driven and can + // never follow the canvas: the fieldset's min-content floor is removed + // (#graph-view-panel min-inline-size: 0) and #graph-canvas clips + // (overflow: hidden). Without both, observer -> setSize -> wider canvas + // -> wider column -> observer is a runaway growth loop that also wipes + // the painted canvas on every step. + const canvasEl = document.getElementById("graph-canvas"); + if (typeof ResizeObserver !== "undefined" && canvasEl) { + let resizeRaf = null; + new ResizeObserver(function () { + if (resizeRaf) return; + resizeRaf = requestAnimationFrame(function () { + resizeRaf = null; + if (!g6graph) return; + try { + const cur = g6graph.getSize(); + const w = canvasEl.clientWidth; + const h = canvasEl.clientHeight; + if (!cur || cur[0] !== w || cur[1] !== h) g6graph.setSize(w, h); + } catch (_err) { /* instance mid-recreate */ } + }); + }).observe(canvasEl); + } + + if (graphColumn) { + document.addEventListener("keydown", function (ev) { + if (ev.key === "Escape" + && graphColumn.classList.contains("graph-view-expanded")) { + setExpanded(false); + } + }); + } + + const foldToggle = document.getElementById("graph-fold-toggle"); + if (foldToggle) { + foldToggle.checked = foldEnabled(); + foldToggle.addEventListener("change", function () { + localStorage.setItem("graph-fold-containers", foldToggle.checked ? "1" : "0"); + renderCurrent(); + }); + } + + const animateToggle = document.getElementById("graph-animate-toggle"); + if (animateToggle) { + animateToggle.checked = animateEnabled(); + animateToggle.addEventListener("change", function () { + localStorage.setItem("graph-animate", animateToggle.checked ? "1" : "0"); + renderCurrent(); + }); + } + + const diffStepsInput = document.getElementById("graph-diff-steps"); + if (diffStepsInput) { + diffStepsInput.value = String(diffSteps()); + diffStepsInput.addEventListener("change", function () { + localStorage.setItem("graph-diff-steps", diffStepsInput.value); + renderCurrent(); + }); + } + + const legendEl = document.getElementById("graph-legend"); + if (legendEl) { + legendEl.addEventListener("click", function (ev) { + const item = ev.target.closest("[data-table]"); + if (!item) return; + const t = item.getAttribute("data-table"); + if (hiddenTables.has(t)) hiddenTables.delete(t); else hiddenTables.add(t); + renderCurrent(); + }); + } + + const diffFoldToggle = document.getElementById("graph-diff-fold"); + if (diffFoldToggle) { + diffFoldToggle.checked = diffFoldEnabled(); + diffFoldToggle.addEventListener("change", function () { + localStorage.setItem("graph-diff-fold", diffFoldToggle.checked ? "1" : "0"); + renderCurrent(); + }); + } + + const depthFoldInput = document.getElementById("graph-depth-fold"); + if (depthFoldInput) { + const v = depthFoldValue(); + depthFoldInput.value = v == null ? "" : String(v); + depthFoldInput.addEventListener("change", function () { + localStorage.setItem("graph-depth-fold", depthFoldInput.value); + // A positive depth is meaningless without combos — switch them on. + if (parseInt(depthFoldInput.value, 10) > 0 && foldToggle && !foldToggle.checked) { + foldToggle.checked = true; + localStorage.setItem("graph-fold-containers", "1"); + } + renderCurrent(); + }); + } + + const layoutSelect = document.getElementById("graph-layout-select"); + if (layoutSelect) { + Object.keys(LAYOUTS).forEach(function (name) { + const opt = document.createElement("option"); + opt.value = name; + opt.textContent = name; + layoutSelect.appendChild(opt); + }); + layoutSelect.value = currentLayoutName(); + layoutSelect.addEventListener("change", function () { + localStorage.setItem("graph-layout", layoutSelect.value); + renderCurrent(); + }); + } + + const renderAnywayBtn = document.getElementById("graph-render-anyway"); + if (renderAnywayBtn) { + renderAnywayBtn.addEventListener("click", function () { + renderForced = true; + renderCurrent(); + }); + } + + const filterResetBtn = document.getElementById("graph-filter-reset"); + if (filterResetBtn) { + filterResetBtn.addEventListener("click", function () { + graphFilterIds = null; + graphFilterQuery = null; + renderCurrent(); + }); + } + + // Compact the loaded-at timestamp to local HH:MM; full instant on hover. + const loadedAtEl = document.getElementById("graph-loaded-at"); + if (loadedAtEl) { + const d = new Date(loadedAtEl.textContent.trim()); + if (!isNaN(d.getTime())) { + loadedAtEl.title = loadedAtEl.textContent.trim(); + loadedAtEl.textContent = String(d.getHours()).padStart(2, "0") + + ":" + String(d.getMinutes()).padStart(2, "0"); + } + } + + connect(); + refreshSyncStatus(); + refetchGraph(); + + window.addEventListener("beforeunload", function () { + wsClosing = true; + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(encodeUnsubscribe(fileId)); + ws.close(); + } + }); +})(); +</script> +{% endif %} +{% endblock %} diff --git a/backend/resources/app/templates/styles.css b/backend/resources/app/templates/styles.css index bbcc3fbb48..56c74d594a 100644 --- a/backend/resources/app/templates/styles.css +++ b/backend/resources/app/templates/styles.css @@ -143,6 +143,35 @@ nav > div:not(:last-child) { height: fit-content; } +/* A widget that holds a table rather than a form: full width, and tall + enough to be worth scrolling inside. */ +.dashboard.wide { + margin-top: 0px; +} + +.widget.wide { + max-width: none; + width: 100%; +} + +.widget.wide .scroll-box { + max-height: 320px; + overflow-y: auto; + margin-top: 10px; +} + +.widget.wide table { + width: 100%; + border-collapse: collapse; +} + +.widget.wide th { + text-align: left; + position: sticky; + top: 0; + background: white; +} + .widget input[type=submit] { outline: none; border: 1px solid gray; diff --git a/backend/resources/climit.edn b/backend/resources/climit.edn index 7d8234499b..ded9b5c9b8 100644 --- a/backend/resources/climit.edn +++ b/backend/resources/climit.edn @@ -39,4 +39,16 @@ {:permits 3} :create-file-snapshot/by-profile - {:permits 1 :queue 2 :timeout 60000}} + {:permits 1 :queue 2 :timeout 60000} + + :send-user-feedback/global + {:permits 4} + + :send-user-feedback/by-profile + {:permits 1 :queue 3} + + :import-binfile/global + {:permits 4} + + :import-binfile/by-profile + {:permits 1 :queue 2}} diff --git a/backend/resources/rlimit.edn b/backend/resources/rlimit.edn index 118f30f70a..68b3153848 100644 --- a/backend/resources/rlimit.edn +++ b/backend/resources/rlimit.edn @@ -1,11 +1,308 @@ -;; Example rlimit.edn file ^{:refresh "30s"} {:default [[:default :window "200000/h"]] - ;; #{:main/get-teams} - ;; [[:burst :bucket "5/5/5s"]] + ;; ═══════════════════════════════════════════════ + ;; Auth & Identity — public, unauthenticated + ;; ═══════════════════════════════════════════════ + #{:main/login-with-password} + [[:auth-password :bucket "100/50/1m"]] - ;; #{:main/get-profile} - ;; [[:burst :bucket "60/60/1m"]] - } + #{:main/login-with-ldap} + [[:auth-ldap :bucket "20/10/5m"]] + + #{:main/register-profile} + [[:auth-register :bucket "20/10/15m"]] + + #{:main/request-profile-recovery + :main/prepare-register-profile} + [[:auth-recovery :bucket "100/50/5m"]] + + #{:main/recover-profile + :main/verify-token} + [[:auth-token :bucket "100/50/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; SSRF vectors — URL fetch endpoints + ;; ═══════════════════════════════════════════════ + #{:main/create-file-media-object-from-url} + [[:url-fetch :bucket "100/50/5m"]] + + #{:main/create-webhook + :main/update-webhook} + [[:webhook-validation :bucket "20/10/5m"]] + + ;; ═══════════════════════════════════════════════ + ;; Search — full sequential scan risk + ;; ═══════════════════════════════════════════════ + #{:main/search-files} + [[:search :bucket "60/30/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Feedback & Invitations — email-sending + ;; ═══════════════════════════════════════════════ + #{:main/send-user-feedback + :main/create-team-invitations} + [[:email-send :bucket "30/15/5m"]] + + ;; ═══════════════════════════════════════════════ + ;; Media & File heavy ops + ;; ═══════════════════════════════════════════════ + #{:main/upload-file-media-object} + [[:image-upload :bucket "200/100/1m"]] + + #{:main/create-file-object-thumbnail + :main/delete-file-object-thumbnails + :main/get-file-object-thumbnails} + [[:thumbnail-ops :bucket "5000/3000/1m"]] + + #{:main/get-file-data-for-thumbnail + :main/create-file-thumbnail} + [[:thumbnail-data :bucket "100/50/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; UI navigation reads — high frequency + ;; ═══════════════════════════════════════════════ + #{:main/get-teams} + [[:get-teams :bucket "5000/2500/30s"]] + + #{:main/get-team-members} + [[:get-team-members :bucket "4000/2000/30s"]] + + #{:main/get-profile} + [[:get-profile :bucket "500/250/30s"]] + + #{:main/get-font-variants} + [[:get-font-variants :bucket "250/125/30s"]] + + #{:main/get-comment-threads} + [[:get-comment-threads :bucket "500/250/30s"]] + + #{:main/get-profiles-for-file-comments} + [[:get-profiles-for-file-comments :bucket "300/150/30s"]] + + #{:main/get-file-libraries} + [[:get-file-libraries :bucket "200/100/30s"]] + + #{:main/get-projects} + [[:get-projects :bucket "120/60/30s"]] + + #{:main/get-team-recent-files + :main/get-unread-comment-threads} + [[:get-team-recent :bucket "120/60/30s"]] + + #{:main/get-page} + [[:get-page :bucket "150/75/30s"]] + + #{:main/get-access-tokens + :main/get-subscription-usage} + [[:get-access-tokens :bucket "150/75/30s"]] + + #{:main/get-enabled-flags} + [[:get-enabled-flags :bucket "250/125/30s"]] + + #{:main/get-builtin-templates} + [[:get-builtin-templates :bucket "200/100/30s"]] + + #{:main/get-project + :main/get-project-files} + [[:get-project-info :bucket "80/40/30s"]] + + #{:main/get-file} + [[:get-file :bucket "180/90/1m"]] + + #{:main/get-team-shared-files + :main/get-team-info + :main/get-team-users + :main/get-team-invitations + :main/get-team-deleted-files + :main/get-sso-provider} + [[:get-team-info :bucket "60/30/30s"]] + + #{:main/get-comments + :main/get-file-snapshots + :main/get-library-usage + :main/has-file-libraries} + [[:get-misc-list :bucket "300/150/30s"]] + + #{:main/get-comment-thread + :main/get-library-file-references} + [[:get-misc-single :bucket "60/30/30s"]] + + #{:main/get-file-info + :main/get-view-only-bundle + :main/get-all-projects + :main/get-owned-teams + :main/get-team-stats + :main/get-file-summary + :main/get-file-stats + :main/get-file-fragment} + [[:get-light :bucket "60/30/30s"]] + + ;; ═══════════════════════════════════════════════ + ;; File mutations — editing active + ;; ═══════════════════════════════════════════════ + #{:main/update-file} + [[:update-file :bucket "1000/500/1m"]] + + #{:main/create-file + :main/rename-file + :main/duplicate-file + :main/move-files} + [[:file-create :bucket "60/30/1m"]] + + #{:main/delete-file} + [[:file-delete :bucket "80/40/1m"]] + + #{:main/set-file-shared + :main/update-file-library-sync-status + :main/ignore-file-library-sync-status + :main/link-file-to-library + :main/unlink-file-from-library + :main/create-file-snapshot + :main/restore-file-snapshot + :main/update-file-snapshot + :main/delete-file-snapshot + :main/lock-file-snapshot + :main/unlock-file-snapshot} + [[:file-mutations :bucket "80/40/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Project mutations + ;; ═══════════════════════════════════════════════ + #{:main/create-project} + [[:project-create :bucket "100/50/1m"]] + + #{:main/delete-project + :main/rename-project + :main/duplicate-project + :main/move-project + :main/update-project-pin} + [[:project-mutations :bucket "40/20/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Team mutations + ;; ═══════════════════════════════════════════════ + #{:main/create-team + :main/update-team + :main/delete-team + :main/update-team-photo + :main/update-team-member-role + :main/delete-team-member + :main/leave-team + :main/create-team-with-invitations + :main/create-team-access-request + :main/permanently-delete-team-files + :main/restore-deleted-team-files} + [[:team-mutations :bucket "60/30/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Comment operations + ;; ═══════════════════════════════════════════════ + #{:main/create-comment-thread + :main/create-comment + :main/update-comment + :main/delete-comment + :main/mark-all-threads-as-read} + [[:comment-basic :bucket "30/15/1m"]] + + #{:main/update-comment-thread + :main/update-comment-thread-status + :main/update-comment-thread-position + :main/update-comment-thread-frame + :main/delete-comment-thread} + [[:comment-thread :bucket "80/40/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Profile operations + ;; ═══════════════════════════════════════════════ + #{:main/update-profile + :main/update-profile-props + :main/update-profile-photo + :main/update-profile-password + :main/update-profile-notifications + :main/delete-profile + :main/delete-profile-photo + :main/request-email-change} + [[:profile-mutations :bucket "30/15/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Font operations + ;; ═══════════════════════════════════════════════ + #{:main/create-font-variant + :main/delete-font + :main/delete-font-variant + :main/update-font + :main/download-font + :main/download-font-family} + [[:font-ops :bucket "100/50/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Access tokens + ;; ═══════════════════════════════════════════════ + #{:main/create-access-token + :main/delete-access-token} + [[:access-token :bucket "60/30/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Export / Import + ;; ═══════════════════════════════════════════════ + #{:main/export-binfile + :main/import-binfile + :main/clone-template} + [[:export-import :bucket "80/40/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Upload sessions + ;; ═══════════════════════════════════════════════ + #{:main/create-upload-session + :main/upload-chunk + :main/assemble-file-media-object} + [[:upload-session :bucket "100/50/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Webhooks + ;; ═══════════════════════════════════════════════ + #{:main/get-webhooks + :main/delete-webhook} + [[:webhook-read :bucket "20/10/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Share links + ;; ═══════════════════════════════════════════════ + #{:main/create-share-link + :main/delete-share-link} + [[:share-link :bucket "10/5/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Organization operations + ;; ═══════════════════════════════════════════════ + #{:main/add-team-to-organization + :main/remove-team-from-org + :main/all-org-members-in-team + :main/all-team-members-in-orgs + :main/get-owned-organizations-summary + :main/get-leave-org-summary + :main/leave-org + :main/check-org-members + :main/get-team-invitation-token + :main/delete-team-invitation + :main/check-team-external-invitations} + [[:org-ops :bucket "20/10/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Audit & stats + ;; ═══════════════════════════════════════════════ + #{:main/push-audit-events} + [[:audit-events :bucket "1000/500/1m"]] + + #{:main/logout + :main/get-error-report + :main/get-error-reports + :main/get-current-mcp-token + :main/get-nitrate-connectivity + :main/check-nitrate-sso + :main/redeem-nitrate-activation-code + :main/create-demo-profile + :main/get-subscription-warning} + [[:misc-light :bucket "100/50/1m"]]} diff --git a/backend/scripts/_env b/backend/scripts/_env index 120bb648bc..5e4b02b80a 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -4,6 +4,7 @@ export PENPOT_ADMIN_CONSOLE_SHARED_KEY=super-secret-nitrate-api-key export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key export PENPOT_NEXUS_SHARED_KEY=super-secret-nexus-api-key export PENPOT_SECRET_KEY=super-secret-devenv-key +export PENPOT_MEDIA_PROCESSOR_SHARED_KEY=super-secret-media-processor-key # DEPRECATED: only used for subscriptions export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key @@ -12,6 +13,10 @@ export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key # PENPOT_DATABASE_*, PENPOT_REDIS_URI, PENPOT_OBJECTS_STORAGE_*, AWS_*) is owned by # docker/devenv/defaults.env and injected via the main service's env block. +if [ -f /home/selfsigned.crt ]; then + export NODE_EXTRA_CA_CERTS=/home/selfsigned.crt; +fi + # Background worker flag is per-instance. Defaults to enabled (ws0); ws1+ # overlays set PENPOT_BACKEND_WORKER=false so scheduled and async tasks only # run on ws0, keeping notification Pub/Sub bound to a single Valkey. See @@ -21,6 +26,8 @@ if [[ "${PENPOT_BACKEND_WORKER:-true}" == "true" ]]; then __worker_flag="enable-backend-worker" fi +export PENPOT_MEDIA_PROCESSING_SERVICE_URI=http://localhost:6065 + export PENPOT_FLAGS="\ $PENPOT_FLAGS \ enable-login-with-password \ @@ -36,6 +43,7 @@ export PENPOT_FLAGS="\ enable-feature-fdata-objects-map \ enable-audit-log \ enable-transit-readable-response \ + disable-remote-media-processing \ enable-demo-users \ enable-user-feedback \ disable-secure-session-cookies \ @@ -85,7 +93,8 @@ export JAVA_OPTS="\ -XX:-OmitStackTraceInFastThrow \ --sun-misc-unsafe-memory-access=allow \ --enable-preview \ - --enable-native-access=ALL-UNNAMED"; + --enable-native-access=ALL-UNNAMED \ + --add-opens=java.base/java.nio=ALL-UNNAMED"; function setup_minio() { if [ "${PENPOT_OBJECTS_STORAGE_BACKEND}" != "s3" ]; then @@ -97,5 +106,3 @@ function setup_minio() { mc alias set penpot-s3/ "${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}" minioadmin minioadmin -q mc mb "penpot-s3/${PENPOT_OBJECTS_STORAGE_S3_BUCKET}" -p -q } - - diff --git a/backend/scripts/manage.py b/backend/scripts/manage.py index 56141571e7..e0e7780595 100755 --- a/backend/scripts/manage.py +++ b/backend/scripts/manage.py @@ -4,7 +4,7 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. # -# Copyright (c) KALEIDOS INC Sucursal en España SL +# Copyright (c) KALEIDOS SUBSIDIARY SL import argparse import json diff --git a/backend/scripts/run.template.sh b/backend/scripts/run.template.sh index cff4afc870..19f47e6c0a 100644 --- a/backend/scripts/run.template.sh +++ b/backend/scripts/run.template.sh @@ -18,7 +18,7 @@ if [ -f ./environ ]; then source ./environ fi -export JAVA_OPTS="-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager -Dlog4j2.configurationFile=log4j2.xml -XX:-OmitStackTraceInFastThrow --sun-misc-unsafe-memory-access=allow --enable-native-access=ALL-UNNAMED --enable-preview $JVM_OPTS $JAVA_OPTS" +export JAVA_OPTS="-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager -Dlog4j2.configurationFile=log4j2.xml -XX:-OmitStackTraceInFastThrow --sun-misc-unsafe-memory-access=allow --enable-native-access=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --enable-preview $JVM_OPTS $JAVA_OPTS" ENTRYPOINT=${1:-app.main}; diff --git a/backend/src/app/auth.clj b/backend/src/app/auth.clj index 1f978f357c..54ccc04303 100644 --- a/backend/src/app/auth.clj +++ b/backend/src/app/auth.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.auth (:require @@ -14,10 +14,21 @@ :iterations 3 :parallelism 2}) +(def ^:private weak-options + {:alg :pbkdf2+sha256 + :iterations 100}) + (defn derive-password [password] (hashers/derive password default-options)) +(defn derive-password-weak + "Derives a password using a fast algorithm (pbkdf2+sha256, 100 iterations). + Intended for demo users only — they are already gated behind the + `demo-users` config flag which is disabled in production." + [password] + (hashers/derive password weak-options)) + (defn verify-password [attempt password] (try diff --git a/backend/src/app/auth/ldap.clj b/backend/src/app/auth/ldap.clj index 659e990c91..8dae1291d6 100644 --- a/backend/src/app/auth/ldap.clj +++ b/backend/src/app/auth/ldap.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.auth.ldap (:require diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 09d34532b9..b3fdc80d7e 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.auth.oidc "OIDC client implementation." @@ -42,31 +42,52 @@ ;; OIDC PROVIDER (GENERIC) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defn- raise-invalid-sso-config + "Raise a controlled validation error for OIDC provider configuration failures." + [& {:keys [hint cause] :as params}] + (throw (ex-info (or hint "invalid-sso-config") + (-> params + (dissoc :cause) + (assoc :type :validation + :code :invalid-sso-config)) + cause))) + (defn- discover-oidc-config [cfg {:keys [base-uri skip-ssrf-check?] :as provider}] - (let [uri (u/join base-uri ".well-known/openid-configuration") - rsp (http/req cfg {:method :get :uri (dm/str uri)} {:skip-ssrf-check? skip-ssrf-check?})] + (let [uri (u/join base-uri ".well-known/openid-configuration")] + (try + (let [rsp (http/req cfg {:method :get :uri (dm/str uri)} {:skip-ssrf-check? skip-ssrf-check?})] + (if (= 200 (:status rsp)) + (let [data (-> rsp :body json/decode) + token-uri (get data :token_endpoint) + auth-uri (get data :authorization_endpoint) + user-uri (get data :userinfo_endpoint) + jwks-uri (get data :jwks_uri) + logout-uri (get data :end_session_endpoint)] - (if (= 200 (:status rsp)) - (let [data (-> rsp :body json/decode) - token-uri (get data :token_endpoint) - auth-uri (get data :authorization_endpoint) - user-uri (get data :userinfo_endpoint) - jwks-uri (get data :jwks_uri) - logout-uri (get data :end_session_endpoint)] + (-> provider + (assoc :token-uri token-uri) + (assoc :auth-uri auth-uri) + (assoc :user-uri user-uri) + (assoc :jwks-uri jwks-uri) + (assoc :logout-uri logout-uri))) - (-> provider - (assoc :token-uri token-uri) - (assoc :auth-uri auth-uri) - (assoc :user-uri user-uri) - (assoc :jwks-uri jwks-uri) - (assoc :logout-uri logout-uri))) - - (ex/raise :type ::internal - :code :invalid-sso-config - :hint "unable to discover OIDC configuration" - :discover-uri uri - :response-status-code (:status rsp))))) + (raise-invalid-sso-config + :hint "unable to discover OIDC configuration" + :discover-uri uri + :response-status-code (:status rsp)))) + (catch Throwable cause + ;; Controlled raises above are ExceptionInfo and would otherwise be + ;; re-wrapped by this catch, dropping fields like :response-status-code. + (if (and (ex/error? cause) + (= :invalid-sso-config (:code (ex-data cause)))) + (throw cause) + ;; Wrap SSRF blocks, DNS failures, TLS errors, etc. — from the caller's + ;; perspective these are all "bad/unreachable issuer URL". + (raise-invalid-sso-config + :hint "unable to discover OIDC configuration" + :discover-uri uri + :cause cause)))))) (def ^:private default-oidc-scopes #{"openid" "profile" "email"}) @@ -107,16 +128,29 @@ (defn- fetch-oidc-jwks [cfg jwks-uri {:keys [skip-ssrf-check?]}] - (let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri} {:skip-ssrf-check? skip-ssrf-check?})] - (if (= 200 status) - (-> body json/decode :keys process-oidc-jwks) - (ex/raise :type ::internal - :code :unable-to-fetch-sso-jwks - :hint "unable to retrieve JWKs (unexpected response status code)" - :response-status-code status)))) + (try + (let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri} {:skip-ssrf-check? skip-ssrf-check?})] + (if (= 200 status) + (-> body json/decode :keys process-oidc-jwks) + (raise-invalid-sso-config + :hint "unable to retrieve JWKs (unexpected response status code)" + :jwks-uri jwks-uri + :response-status-code status))) + (catch Throwable cause + (if (and (ex/error? cause) + (= :invalid-sso-config (:code (ex-data cause)))) + (throw cause) + (raise-invalid-sso-config + :hint "unable to retrieve JWKs" + :jwks-uri jwks-uri + :cause cause))))) (defn- populate-jwks - "Fetch and Add (if possible) JWK's to the OIDC provider" + "Fetch and add JWKs to the OIDC provider. + + When `:strict-jwks?` is set (organization SSO), failures raise a controlled + validation error. Otherwise JWKS is best-effort: log and continue without keys + so global OIDC/GitLab providers can still initialize if JWKS is temporarily down." [cfg provider] (try (if-let [jwks (when-let [jwks-uri (:jwks-uri provider)] @@ -124,20 +158,28 @@ (assoc provider :jwks jwks) provider) (catch Throwable cause - (l/warn :hint "unable to fetch JWKs for the OIDC provider" - :provider (str (:id provider)) - :cause cause) - provider))) + (if (:strict-jwks? provider) + (if (and (ex/error? cause) + (= :invalid-sso-config (:code (ex-data cause)))) + (throw cause) + (raise-invalid-sso-config + :hint "unable to retrieve JWKs" + :provider (:id provider) + :cause cause)) + (do + (l/warn :hint "unable to fetch JWKs for the OIDC provider" + :provider (str (:id provider)) + :cause cause) + provider))))) (defn- prepare-oidc-provider [cfg params] (when-not (and (string? (:base-uri params)) (string? (:client-id params)) (string? (:client-secret params))) - (ex/raise :type ::internal - :code :invalid-sso-config - :hint "missing params for provider initialization" - :provider (:id params))) + (raise-invalid-sso-config + :hint "missing params for provider initialization" + :provider (:id params))) (try (if (and (string? (:token-uri params)) @@ -150,11 +192,13 @@ (with-meta provider {::discovered true}))) (catch Throwable cause - (ex/raise :type ::internal - :type :invalid-sso-config - :hint "unexpected exception on configuring provider" - :provider (:id params) - :cause cause)))) + (if (and (ex/error? cause) + (= :invalid-sso-config (:code (ex-data cause)))) + (throw cause) + (raise-invalid-sso-config + :hint "unexpected exception on configuring provider" + :provider (:id params) + :cause cause))))) (defmethod ig/assert-key ::providers/generic [_ params] @@ -322,10 +366,9 @@ [cfg params] (when-not (and (string? (:client-id params)) (string? (:client-secret params))) - (ex/raise :type ::internal - :code :invalid-sso-config - :hint "missing params for provider initialization" - :provider (:id params))) + (raise-invalid-sso-config + :hint "missing params for provider initialization" + :provider (:id params))) (try (let [provider (populate-jwks cfg params)] @@ -336,11 +379,13 @@ :client-secret (d/obfuscate-string (:client-secret provider))) provider) (catch Throwable cause - (ex/raise :type ::internal - :type :invalid-sso-config - :hint "unexpected exception on configuring provider" - :provider (:id params) - :cause cause)))) + (if (and (ex/error? cause) + (= :invalid-sso-config (:code (ex-data cause)))) + (throw cause) + (raise-invalid-sso-config + :hint "unexpected exception on configuring provider" + :provider (:id params) + :cause cause))))) (defmethod ig/init-key ::providers/gitlab [_ cfg] @@ -620,9 +665,6 @@ (some? (:external-session-id state)) (assoc :external-session-id (:external-session-id state)) - (some? (:token/expires-in tdata)) - (assoc :sso-token-exp (ct/in-future {:seconds (:token/expires-in tdata)})) - ;; If state token comes with props, merge them. The state token ;; props can contain pm_ and utm_ prefixed query params. (map? (:props state)) @@ -650,6 +692,15 @@ (assoc :query (u/map->query-string params)))] (redirect-response uri)))) +(defn- redirect-with-organization-sso-error + [{:keys [dest-url organization-id organization-name]}] + (-> (str (or dest-url (cf/get :public-uri))) + (u/append-query-param :sso-error true) + (u/append-query-param :organization-id organization-id) + (cond-> organization-name + (u/append-query-param :organization-name organization-name)) + (redirect-response))) + (defn- redirect-to-register [cfg info provider] (let [info (assoc info @@ -765,6 +816,82 @@ ;; ORG SSO HELPERS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defn- organization-sso-oauth-failure-reason + [error] + (case (d/name error) + "access_denied" "access-denied" + ("temporarily_unavailable" "server_error") "provider-unavailable" + ("invalid_request" "unauthorized_client" "invalid_scope") "invalid-configuration" + "provider-error")) + +(defn- organization-sso-exception-failure-reason + [cause] + (let [data (ex-data cause) + status (or (:response-status data) + (:response-status-code data) + (:http-status data)) + network-error? + (loop [current cause] + (cond + (nil? current) + false + + (or (instance? java.net.ConnectException current) + (instance? java.net.UnknownHostException current) + (instance? java.net.http.HttpTimeoutException current) + (instance? javax.net.ssl.SSLException current)) + true + + (identical? current (ex-cause current)) + false + + :else + (recur (ex-cause current))))] + (if (or network-error? + (and (number? status) (<= 500 status 599))) + "provider-unavailable" + (case (:code data) + :unable-to-fetch-access-token "token-exchange-failed" + :unable-to-retrieve-user-info "user-info-failed" + :incomplete-user-info "incomplete-user-info" + :invalid-sso-config "invalid-configuration" + :unable-to-fetch-sso-jwks "provider-unavailable" + :unable-to-auth "access-denied" + "unexpected-error")))) + +(defn- submit-organization-sso-auth-event + [cfg request profile-id organization-id name & {:keys [failure-reason]}] + (audit/submit cfg {:type "action" + :name name + :profile-id profile-id + :ip-addr (inet/parse-request request) + :props (d/without-nils + {:organization-id organization-id + :failure-reason failure-reason}) + :context (audit/prepare-context-from-request request)})) + +(defn submit-organization-sso-auth-started-event + [cfg request profile-id organization-id] + (submit-organization-sso-auth-event + cfg request profile-id organization-id "organization-sso-auth-started")) + +(defn submit-organization-sso-auth-failed-event + [cfg request profile-id organization-id cause] + (submit-organization-sso-auth-event + cfg request profile-id organization-id "organization-sso-auth-failed" + :failure-reason (organization-sso-exception-failure-reason cause))) + +(defn- submit-organization-sso-oauth-failed-event + [cfg request state-token error] + (try + (let [state (tokens/verify cfg {:token state-token :iss "oidc"})] + (when (:dest-url state) + (submit-organization-sso-auth-event + cfg request (some-> (session/get-session request) :profile-id) + (:organization-id state) "organization-sso-auth-failed" + :failure-reason (organization-sso-oauth-failure-reason error)))) + (catch Exception _ nil))) + (defn- non-blank-uri [value] (when-not (str/blank? value) value)) @@ -776,7 +903,7 @@ (defn prepare-organization-sso-provider "Build an OIDC provider map dynamically from the Nitrate organization SSO config. - Uses OIDC discovery via :issuer when token/auth/user URIs are absent." + Uses OIDC discovery via :issuer when token/auth/user URIs are absent." [cfg {:keys [client-id client-secret issuer]}] (prepare-oidc-provider cfg {:type "oidc" @@ -786,7 +913,9 @@ (str/rtrim "/") (str "/")) :scopes default-oidc-scopes - :skip-ssrf-check? true})) + ;; Organization SSO is configured by customers; discovery + ;; and JWKS failures must surface as controlled errors. + :strict-jwks? true})) (defn build-organization-sso-auth-redirect-uri "Build the OIDC authorization redirect URI for an organization SSO config. @@ -796,16 +925,24 @@ issuer (organization-sso-discovery-uri sso) dest-url (or dest-url (str (cf/get :public-uri)))] (when-not issuer - (ex/raise :type :validation - :code :invalid-sso-config - :hint "missing issuer")) - (let [oidc-provider (or provider (prepare-organization-sso-provider cfg sso)) - state-token (tokens/generate cfg {:iss "oidc" - :dest-url dest-url - :organization-id organization-id - :issuer issuer - :exp (ct/in-future "4h")})] - (build-auth-redirect-uri oidc-provider state-token)))) + (raise-invalid-sso-config + :hint "missing issuer" + :organization-id organization-id)) + (try + (let [oidc-provider (or provider (prepare-organization-sso-provider cfg sso)) + state-token (tokens/generate cfg {:iss "oidc" + :dest-url dest-url + :organization-id organization-id + :issuer issuer + :exp (ct/in-future "4h")})] + (build-auth-redirect-uri oidc-provider state-token)) + (catch Throwable cause + (if (and (ex/error? cause) + (= :invalid-sso-config (:code (ex-data cause)))) + (throw (ex-info (ex-message cause) + (assoc (ex-data cause) :organization-id organization-id) + (ex-cause cause))) + (throw cause)))))) (def ^:private probe-auth-code "penpot-sso-config-probe") @@ -888,10 +1025,53 @@ {::yres/status 200 ::yres/body {:redirect-uri uri}})) +(defn- organization-sso-callback-handler + "Handle the organization-SSO branch of the OIDC callback: state carries + :dest-url — exchange the authorization code with the OIDC provider to + verify authentication actually occurred, then redirect back to dest-url." + [cfg request state code] + (let [dest-url (:dest-url state)] + (try + (let [organization-id (:organization-id state) + sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id}) + provider (prepare-organization-sso-provider cfg sso) + _info (get-info cfg provider state code) + session (session/get-session request) + exp (ct/in-future {:minutes 15})] + (when (and session organization-id) + (let [props (-> (or (:props session) {}) + (update :sso assoc organization-id exp))] + (session/update-session (::session/manager cfg) (assoc session :props props)))) + (submit-organization-sso-auth-event + cfg request (:profile-id session) organization-id "organization-sso-auth-succeeded") + (redirect-response dest-url)) + (catch Throwable cause + (let [{:keys [code]} (ex-data cause)] + (binding [l/*context* (errors/request->context request)] + (if (some? code) + (l/warn :hint "organization sso callback failed" + :code code + :message (ex-message cause) + :organization-id (:organization-id state)) + (l/err :hint "unexpected error on organization sso callback" + :organization-id (:organization-id state) + :cause cause)))) + (submit-organization-sso-auth-failed-event + cfg request (some-> (session/get-session request) :profile-id) + (:organization-id state) cause) + (let [organization-id (:organization-id state) + organization-name (:name (nitrate/call cfg :get-organization-summary {:organization-id organization-id}))] + (redirect-with-organization-sso-error + {:dest-url dest-url + :organization-id organization-id + :organization-name organization-name})))))) + (defn- callback-handler [cfg {:keys [params] :as request}] (if-let [error (get params :error)] - (redirect-with-error "unable-to-auth" error) + (do + (submit-organization-sso-oauth-failed-event cfg request (:state params) error) + (redirect-with-error "unable-to-auth" error)) (try (let [code (get params :code) state (get params :state) @@ -899,18 +1079,8 @@ ;; Organization SSO flow: state carries :dest-url — exchange the authorization ;; code with the OIDC provider to verify authentication actually occurred. - (if-let [dest-url (:dest-url state)] - (let [organization-id (:organization-id state) - sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id}) - provider (prepare-organization-sso-provider cfg sso) - info (get-info cfg provider state code) - session (session/get-session request) - exp (or (:sso-token-exp info) (ct/in-future {:hours 48}))] - (when (and session organization-id) - (let [props (-> (or (:props session) {}) - (update :sso assoc organization-id exp))] - (session/update-session (::session/manager cfg) (assoc session :props props)))) - (redirect-response dest-url)) + (if (:dest-url state) + (organization-sso-callback-handler cfg request state code) (let [provider (resolve-provider cfg state) info (get-info cfg provider state code) diff --git a/backend/src/app/auth/passwords.clj b/backend/src/app/auth/passwords.clj new file mode 100644 index 0000000000..d4e8cef200 --- /dev/null +++ b/backend/src/app/auth/passwords.clj @@ -0,0 +1,56 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.auth.passwords + "Password strength validation using Passay library." + (:require + [app.common.exceptions :as ex]) + (:import + [org.passay PasswordData] + [org.passay.data EnglishCharacterData] + [org.passay.rule CharacterCharacteristicsRule CharacterRule])) + +(defonce ^:private passay-code->translation-key + {"INSUFFICIENT_LOWERCASE" "errors.weak-password.insufficient-lowercase" + "INSUFFICIENT_UPPERCASE" "errors.weak-password.insufficient-uppercase" + "INSUFFICIENT_DIGIT" "errors.weak-password.insufficient-digits" + "INSUFFICIENT_SPECIAL" "errors.weak-password.insufficient-special"}) + +(defonce ^:private character-characteristics-rule + (CharacterCharacteristicsRule. + 4 + (into-array org.passay.rule.CharacterRule + [(CharacterRule. EnglishCharacterData/LowerCase 1) + (CharacterRule. EnglishCharacterData/UpperCase 1) + (CharacterRule. EnglishCharacterData/Digit 1) + (CharacterRule. EnglishCharacterData/Special 1)]))) + +(defn validate-password + "Validates password strength. + Returns nil if valid, or raises exception if invalid. + Checks: + - Minimum length of 8 characters + - At least 1 lowercase letter + - At least 1 uppercase letter + - At least 1 digit + - At least 1 special character" + [password] + (when (< (count password) 8) + (ex/raise :type :validation + :code :weak-password + :hint "password must be at least 8 characters" + :details ["errors.weak-password.too-short"])) + + (let [password-data (PasswordData. password) + char-result (.validate character-characteristics-rule password-data)] + (when-not (.isValid char-result) + (ex/raise :type :validation + :code :weak-password + :hint "password must contain at least 1 lowercase letter, 1 uppercase letter, 1 digit, and 1 special character" + :details (->> (.getDetails char-result) + (mapv #(.getErrorCode %)) + (mapv passay-code->translation-key) + (filterv some?)))))) diff --git a/backend/src/app/binfile/cleaner.clj b/backend/src/app/binfile/cleaner.clj index 66964b5358..c846a90149 100644 --- a/backend/src/app/binfile/cleaner.clj +++ b/backend/src/app/binfile/cleaner.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.binfile.cleaner "A collection of helpers for perform cleaning of artifacts; mainly diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index a5b73564ea..16652c3526 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.binfile.common "A binfile related file processing common code, used for different @@ -27,7 +27,6 @@ [app.features.file-migrations :as fmigr] [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as-alias webhooks] - [app.storage :as sto] [app.util.blob :as blob] [app.util.pointer-map :as pmap] [app.worker :as-alias wrk] @@ -654,27 +653,6 @@ (db/exec-one! conn ["SET LOCAL idle_in_transaction_session_timeout = 0"]) (db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"]))) -(defn invalidate-thumbnails - [cfg file-id] - (let [storage (sto/resolve cfg) - - sql-1 - (str "update file_tagged_object_thumbnail " - " set deleted_at = now() " - " where file_id=? returning media_id") - - sql-2 - (str "update file_thumbnail " - " set deleted_at = now() " - " where file_id=? returning media_id")] - - (run! #(sto/touch-object! storage %) - (sequence - (keep :media-id) - (concat - (db/exec! cfg [sql-1 file-id]) - (db/exec! cfg [sql-2 file-id])))))) - (defn process-file [cfg {:keys [id] :as file}] (let [libs (delay (get-resolved-file-libraries cfg file))] @@ -723,6 +701,7 @@ (-> (select-keys file file-attrs) (assoc :data nil) (dissoc :team-id) + (dissoc :metadata) (dissoc :migrations))) (defn- file->file-data-params @@ -748,9 +727,17 @@ (fmigr/upsert-migrations! conn file)) (let [file (encode-file cfg file)] - (db/insert! conn :file - (file->params file) - (assoc opts ::db/return-keys false)) + (try + (db/insert! conn :file + (file->params file) + (assoc opts ::db/return-keys false)) + (catch org.postgresql.util.PSQLException cause + (if (db/duplicate-key-error? cause) + (ex/raise :type :not-found + :code :object-not-found + :hint "file already exists" + :cause cause) + (throw cause)))) (->> (file->file-data-params file) (fdata/upsert! cfg)) @@ -866,8 +853,8 @@ (defn get-resolved-file-libraries "Get all file libraries including itself. Returns an instance of LoadableWeakValueMap that allows do not have strong references to - the loaded libraries and reduce possible memory pressure on having - all this libraries loaded at same time on processing file validation + the loaded libraries and reduce memory pressure on having + all this libraries at the same time on processing file validation or file migration. This still requires at least one library at time to be loaded while @@ -879,3 +866,47 @@ (cons (:id file))) load-fn #(get-file cfg % :migrate? false)] (weak/loadable-weak-value-map library-ids load-fn {id file}))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; EXTERNAL LIBRARY RESOLUTION HELPERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn slugify-name + "Slugify a library name for cross-environment matching. + Lowercases, replaces non-alphanumeric runs with '-', strips + leading/trailing '-'." + [name] + (str/slug name)) + +(def ^:private sql:get-files-names + "SELECT id, name FROM file WHERE id = ANY(?)") + +(defn get-files-names + "Return [{:id uuid :name string}] for the given file ids." + [cfg ids] + (db/run! cfg + (fn [{:keys [::db/conn]}] + (let [ids-arr (db/create-array conn "uuid" ids)] + (db/exec! conn [sql:get-files-names ids-arr]))))) + +(def ^:private sql:get-shared-files-for-team + "SELECT f.id, f.name, f.project_id + FROM file AS f + JOIN project AS p ON (p.id = f.project_id) + WHERE p.team_id = ? + AND f.is_shared = true + AND f.deleted_at IS NULL + AND p.deleted_at IS NULL") + +(defn get-shared-files-for-team + "Return [{:id uuid :name string}] for all shared files in a team." + [cfg team-id] + (db/run! cfg + (fn [{:keys [::db/conn]}] + (db/exec! conn [sql:get-shared-files-for-team team-id])))) + +(defn find-shared-files-by-slug + "Return all shared files in `team-id` whose slugified name equals `slug`." + [cfg team-id slug] + (->> (get-shared-files-for-team cfg team-id) + (filter #(= slug (slugify-name (:name %)))))) diff --git a/backend/src/app/binfile/migrations.clj b/backend/src/app/binfile/migrations.clj index ce60eb0f68..9fa5a4b4e9 100644 --- a/backend/src/app/binfile/migrations.clj +++ b/backend/src/app/binfile/migrations.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.binfile.migrations "A binfile related migrations handling" diff --git a/backend/src/app/binfile/v1.clj b/backend/src/app/binfile/v1.clj index 8dc4120159..0f00a7564e 100644 --- a/backend/src/app/binfile/v1.clj +++ b/backend/src/app/binfile/v1.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.binfile.v1 "A custom, perfromance and efficiency focused binfile format impl" @@ -174,6 +174,10 @@ (assert-mark m :obj) (let [size (read-long! input)] (assert (pos? size) "incorrect header size found on reading header") + (when (> size bfc/max-object-size) + (ex/raise :type :validation + :code :max-file-size-reached + :hint (dm/str "unable to import object with size " size " bytes"))) (let [buff (byte-array size)] (read-bytes! input buff) (fres/decode buff))))) diff --git a/backend/src/app/binfile/v2.clj b/backend/src/app/binfile/v2.clj index 347074586b..9e9644c47c 100644 --- a/backend/src/app/binfile/v2.clj +++ b/backend/src/app/binfile/v2.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.binfile.v2 "A sqlite3 based binary file exportation with support for exportation diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index 952cb69e8f..9d4801dd4a 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.binfile.v3 "A ZIP based binary file exportation" @@ -42,6 +42,7 @@ [datoteka.io :as io]) (:import java.io.File + java.io.FilterInputStream java.io.InputStream java.io.OutputStreamWriter java.lang.AutoCloseable @@ -67,7 +68,16 @@ [:relations {:optional true} [:vector - [:tuple ::sm/uuid ::sm/uuid]]]]) + [:tuple ::sm/uuid ::sm/uuid]]] + + ;; TODO: rename to :links + [:external-libraries {:optional true} + [:vector + [:map + [:id ::sm/uuid] + [:name :string] + [:slug :string] + [:used-by {:optional true} [:vector ::sm/uuid]]]]]]) (def ^:private schema:storage-object [:map {:title "StorageObject"} @@ -217,14 +227,12 @@ (.flush writer)) (.closeEntry output)) + (defn- get-file - [{:keys [::bfc/embed-assets ::bfc/include-libraries] :as cfg} file-id] + [{:keys [::bfc/export-type] :as cfg} file-id] - (when (and include-libraries embed-assets) - (throw (IllegalArgumentException. - "the `include-libraries` and `embed-assets` are mutally excluding options"))) - - (let [detach? (and (not embed-assets) (not include-libraries))] + (let [detach? (= export-type :detach-libraries) + embed? (= export-type :merge-libraries)] (db/tx-run! cfg (fn [cfg] (cond-> (bfc/get-file cfg file-id {:realize? true @@ -234,7 +242,7 @@ (-> (ctf/detach-external-references file-id) (dissoc :libraries)) - embed-assets + embed? (update :data #(bfc/embed-assets cfg % file-id)) :always @@ -371,12 +379,34 @@ (write-entry! output path encoded-tokens))))) (defn- export-files - [{:keys [::bfc/ids ::bfc/include-libraries ::output] :as cfg}] - (let [ids (into ids (when include-libraries (bfc/get-libraries cfg ids))) - rels (if include-libraries + [{:keys [::bfc/ids ::bfc/export-type ::output] :as cfg}] + + (let [original-ids ids + ids (into ids (when (= export-type :include-libraries) (bfc/get-libraries cfg ids))) + rels (if (= export-type :include-libraries) (->> (bfc/get-files-rels cfg ids) (mapv (juxt :file-id :library-file-id))) - [])] + []) + + ;; Compute external libraries: referenced by original files but + ;; not included in the export set. Only relevant for :link-later. + external-libs + (when (= export-type :link-later) + (let [original-rels (bfc/get-files-rels cfg original-ids) + lib-ids (into #{} (map :library-file-id) original-rels)] + (when (seq lib-ids) + (let [lib-names (bfc/get-files-names cfg lib-ids)] + (->> lib-names + (mapv (fn [{:keys [id name]}] + (let [slug (bfc/slugify-name name)] + (when-not (str/blank? slug) + {:id id + :name name + :slug slug + :used-by (->> original-rels + (filter #(= (:library-file-id %) id)) + (mapv :file-id))})))) + (filterv some?))))))] (vswap! bfc/*state* assoc :files (d/ordered-map)) @@ -389,12 +419,14 @@ ;; Write manifest file (let [files (:files @bfc/*state*) - params {:type "penpot/export-files" - :version 1 - :generated-by (str "penpot/" (:full cf/version)) - :refer "penpot" - :files (vec (vals files)) - :relations rels}] + params (cond-> {:type "penpot/export-files" + :version 1 + :generated-by (str "penpot/" (:full cf/version)) + :referer "penpot" + :files (vec (vals files)) + :relations rels} + (seq external-libs) + (assoc :external-libraries external-libs))] (write-entry! output "manifest.json" params)))) ;; --- IMPORT IMPL @@ -430,6 +462,32 @@ [^ZipFile input ^ZipEntry entry] (.getInputStream input entry)) +(defn- size-limiting-stream + "Wraps an InputStream to enforce a maximum number of decompressed bytes. + Raises :validation :max-file-size-reached when the limit is exceeded." + ^InputStream + [^InputStream input ^long max-size] + (let [counter (atom 0) + on-read (fn [n] + (when (pos? n) + (when (> (swap! counter + (long n)) max-size) + (ex/raise :type :validation + :code :max-file-size-reached + :hint (str "stream exceeded max size: " max-size)))) + n)] + (proxy [FilterInputStream] [input] + (read + ([] + (let [b (.read input)] + (when (pos? b) (on-read 1)) + b)) + ([^bytes buf] + (on-read (.read input buf 0 (alength buf)))) + ([^bytes buf off] + (on-read (.read input buf (int off) (- (alength buf) (int off))))) + ([^bytes buf off len] + (on-read (.read input buf (int off) (int len)))))))) + (defn- zip-entry-reader [^ZipFile input ^ZipEntry entry] (-> (zip-entry-stream input entry) @@ -438,10 +496,12 @@ (defn- zip-entry-storage-content "Wraps a ZipFile and ZipEntry into a penpot storage compatible object and avoid creating temporal objects" - [input entry] - (let [hash (delay (->> entry - (zip-entry-stream input) - (sto.impl/calculate-hash)))] + [input entry & {:keys [max-size]}] + (let [stream-fn (fn [] + (cond-> (zip-entry-stream input entry) + max-size (size-limiting-stream max-size))) + hash (delay (->> (stream-fn) + (sto.impl/calculate-hash)))] (reify sto.impl/IContentObject (get-size [_] @@ -458,7 +518,7 @@ (throw (UnsupportedOperationException. "not implemented"))) (make-input-stream [_ _] - (zip-entry-stream input entry)) + (stream-fn)) (make-output-stream [_ _] (throw (UnsupportedOperationException. "not implemented")))))) @@ -734,7 +794,7 @@ :plugin-data plugin-data})) (defn- import-file - [{:keys [::db/conn ::bfc/project-id] :as cfg} {file-id :id file-name :name}] + [{:keys [::db/conn ::bfc/project-id ::manifest] :as cfg} {file-id :id file-name :name}] (let [file-id' (bfc/lookup-index file-id) file (read-file cfg file-id) media (read-file-media cfg file-id) @@ -801,8 +861,10 @@ (assoc :data data) (assoc :name file-name) (assoc :project-id project-id) + (assoc :metadata (d/without-nils + {:generated-by (get manifest :generated-by) + :referer (or (get manifest :referer) (get manifest :refer))})) (dissoc :options)) - file (bfc/process-file cfg file) file (ctf/check-file file)] @@ -833,6 +895,13 @@ [{:keys [::bfc/input ::entries ::bfc/timestamp] :as cfg}] (events/tap :progress {:section :storage-objects}) + ;; IMPORTANT: we strongly do not reuse the main connection that can + ;; run inside a transaction because the storage upload process can + ;; fail in the middle of uploading and leave garbage on the underlying + ;; backend, if we participate in the main transaction and it aborts + ;; we will lose all registry of the pending to reconcile blobs + ;; what the storage subsystem registers in other parallel + ;; transaction (let [storage (sto/resolve cfg) entries (keep (match-storage-entry-fn) entries)] @@ -844,9 +913,9 @@ ext (cmedia/mtype->extension (:content-type object)) path (str "objects/" id ext) - content (->> path - (get-zip-entry input) - (zip-entry-storage-content input))] + content (zip-entry-storage-content input + (get-zip-entry input path) + :max-size (::bfc/import-max-object-size cfg))] (when (not= (:size object) (sto/get-size content)) (ex/raise :type :validation @@ -856,6 +925,15 @@ :expected-size (:size object) :found-size (sto/get-size content))) + (when-let [max (::bfc/import-max-object-size cfg)] + (when (> (sto/get-size content) max) + (ex/raise :type :validation + :code :max-file-size-reached + :hint (str "storage object exceeds maximum size: " (sto/get-size content)) + :path path + :max max + :found (sto/get-size content)))) + (when-let [hash (get object :hash)] (when (not= hash (sto/get-hash content)) (ex/raise :type :validation @@ -880,6 +958,104 @@ (vswap! bfc/*state* update :index assoc id (:id sobject))))))) +(defn- add-to-file + "Add a resolved library entry to a file in the file-grouped resolution. + `key` is :done (auto-linked) or :pending (needs resolution)." + [acc file-id file-name key entry] + (update acc file-id (fn [file] + (let [file (or file {:id file-id + :name file-name + :done [] + :pending []})] + (update file key conj entry))))) + +(defn- compute-link-decisions + "Returns a map of {old-lib-id -> {:library-id ... :library ...}} for external + libraries that should be auto-linked (single candidate AND importer has edit + permission). Libraries with zero or multiple candidates, or where the importer + lacks permission, are excluded — their refs should remain dangling." + [{:keys [::db/conn ::manifest ::bfc/team-id ::bfc/profile-id] :as cfg}] + (reduce + (fn [acc ext-lib] + (let [slug (:slug ext-lib)] + (if (nil? slug) + acc + (let [matching (into [] (bfc/find-shared-files-by-slug cfg team-id slug))] + (if (not= 1 (count matching)) + acc + (let [library (first matching) + perms (bfc/get-file-permissions conn profile-id (:id library))] + (if (:can-edit perms) + (assoc acc (:id ext-lib) {:library-id (:id library) + :library library}) + acc))))))) + {} + (:external-libraries manifest))) + +(defn- resolve-and-link-libraries + "For each external library in the manifest, resolve candidates by slug. + Auto-links single matches (creating DB rows) and builds a file-grouped + resolution map keyed by imported file-id (new UUID)." + + [{:keys [::db/conn ::manifest ::bfc/team-id ::bfc/timestamp] :as cfg} files-info] + (assert (uuid? team-id) "team-id should be provided") + + (let [file-ids (keys files-info) + decisions (compute-link-decisions cfg)] + + (reduce + (fn [acc ext-lib] + (assert (contains? ext-lib :id) "expected `:id` on ext-lib") + (assert (contains? ext-lib :name) "expected `:name` on ext-lib") + (assert (contains? ext-lib :used-by) "expected `:used-by` on ext-lib") + (assert (contains? ext-lib :slug) "expected `:slug` on ext-lib") + + (let [used-by (into #{} (map bfc/lookup-index) (:used-by ext-lib))] + (cond + ;; No slug → skip + (nil? (:slug ext-lib)) + acc + + ;; Has decision → auto-link (single match + can-edit) + (contains? decisions (:id ext-lib)) + (let [{:keys [library-id]} (get decisions (:id ext-lib)) + used-by (filter used-by file-ids)] + (doseq [file-id used-by] + (let [rel-params {:file-id file-id :library-file-id library-id}] + (db/insert! conn :file-library-rel rel-params + {::db/on-conflict-do-nothing? true}) + (bfc/upsert-file-library-sync! conn (assoc rel-params :synced-at timestamp)))) + (let [entry {:id (:id ext-lib) + :name (:name ext-lib) + :linked-to library-id}] + (reduce (fn [acc file-id] + (add-to-file acc file-id (get files-info file-id) :done entry)) + acc used-by))) + + ;; Has candidates but no decision → multi-match or no permission → pending + :else + (let [matching-libraries (into [] (bfc/find-shared-files-by-slug cfg team-id (:slug ext-lib)))] + (if (empty? matching-libraries) + acc + (let [candidates (mapv (fn [lib] + (let [project-id (:project-id lib) + project (bfc/get-project cfg project-id) + project-name (:name project)] + {:id (:id lib) + :name (:name lib) + :project-id project-id + :project-name project-name})) + matching-libraries) + entry {:id (:id ext-lib) + :name (:name ext-lib) + :candidates candidates}] + (reduce (fn [acc file-id] + (add-to-file acc file-id (get files-info file-id) :pending entry)) + acc used-by))))))) + + {} + (:external-libraries manifest)))) + (defn- import-files* [{:keys [::manifest] :as cfg}] (bfc/disable-database-timeouts! cfg) @@ -888,18 +1064,58 @@ (import-storage-objects cfg) - (let [files (get manifest :files) - result (reduce (fn [result file] - (let [name' (get file :name) - file (assoc file :name name')] - (conj result (import-file cfg file)))) - [] - files)] + ;; Pre-resolve external libraries and add their id mappings to the index + ;; BEFORE importing files. This allows relink-refs (inside process-file) + ;; to correctly remap :component-file references to the destination library. + ;; Only remap when a link will actually be created (single match + can-edit). + (let [decisions (compute-link-decisions cfg)] + (doseq [[old-lib-id {:keys [library-id]}] decisions] + (l/trc :hint "pre-resolving external library" + :old-id (str old-lib-id) + :new-id (str library-id)) + (vswap! bfc/*state* update :index assoc old-lib-id library-id))) + + (let [files (get manifest :files) + file-ids (reduce (fn [result file] + (let [name' (get file :name) + file (assoc file :name name')] + (conj result (import-file cfg file)))) + [] + files) + ;; Build map of file-id to file-name for resolution + files-info (into {} (map (fn [file-id manifest-file] + [file-id (:name manifest-file)]) + file-ids + files))] (import-file-relations cfg) - (bfm/apply-pending-migrations! cfg) - result)) + (let [resolution (resolve-and-link-libraries cfg files-info)] + + (bfm/apply-pending-migrations! cfg) + {:file-ids file-ids + :resolution resolution}))) + +(defn- invalidate-thumbnails + [cfg file-id] + (let [storage (sto/resolve cfg ::db/reuse-conn true) + + sql-1 + (str "update file_tagged_object_thumbnail " + " set deleted_at = now() " + " where file_id=? returning media_id") + + sql-2 + (str "update file_thumbnail " + " set deleted_at = now() " + " where file_id=? returning media_id")] + + (run! #(sto/touch-object! storage %) + (sequence + (keep :media-id) + (concat + (db/exec! cfg [sql-1 file-id]) + (db/exec! cfg [sql-2 file-id])))))) (defn- import-file-and-overwrite* [{:keys [::manifest ::bfc/file-id] :as cfg}] @@ -924,10 +1140,11 @@ (import-storage-objects cfg) (import-file cfg file) - (bfc/invalidate-thumbnails cfg file-id) + (invalidate-thumbnails cfg file-id) (bfm/apply-pending-migrations! cfg) - [file-id]))) + {:file-ids [file-id] + :resolution {}}))) (defn- import-files [{:keys [::bfc/timestamp ::bfc/input] :or {timestamp (ct/now)} :as cfg}] @@ -938,6 +1155,15 @@ (let [manifest (-> (read-manifest input) (validate-manifest)) entries (read-zip-entries input) + + _ (when-let [max (::bfc/import-max-zip-entries cfg)] + (when (> (count entries) max) + (ex/raise :type :validation + :code :too-many-zip-entries + :hint (str "zip file has too many entries: " (count entries)) + :max max + :found (count entries)))) + cfg (-> cfg (assoc ::entries entries) (assoc ::manifest manifest) @@ -975,12 +1201,11 @@ "Do the exportation of a specified file in custom penpot binary format. There are some options available for customize the output: - `::bfc/include-libraries`: additionally to the specified file, all the - linked libraries also will be included (including transitive - dependencies). - - `::bfc/embed-assets`: instead of including the libraries, embed in the - same file library all assets used from external libraries." + `::bfc/export-type`: determines how linked libraries are handled. + Valid values: `:include-libraries` (include linked libraries), + `:merge-libraries` (embed library assets in the file), + `:detach-libraries` (treat assets as basic objects), + `:link-later` (preserve component metadata for relinking on import)." [{:keys [::bfc/ids] :as cfg} output] @@ -996,6 +1221,7 @@ tp (ct/tpoint) ab (volatile! false) cs (volatile! nil)] + (try (l/info :hint "start exportation" :export-id (str id)) (binding [bfc/*state* (volatile! (bfc/initial-state))] diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 27ca224a88..6f69047382 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.config (:refer-clojure :exclude [get]) @@ -52,7 +52,7 @@ :redis-uri "redis://redis/0" - :file-data-backend "legacy-db" + :file-data-backend "db" :objects-storage-backend "fs" :objects-storage-fs-directory "assets" @@ -94,7 +94,11 @@ ;; SSRF protection :ssrf-allowed-hosts #{} - :ssrf-extra-blocked-cidrs #{}}) + :ssrf-extra-blocked-cidrs #{} + + ;; Binfile import limits + :binfile-import-max-object-size (* 1024 1024 100) ;; 100 MiB + :binfile-import-max-zip-entries (* 500 1000)}) ;; 500,000 (def schema:config (do #_sm/optional-keys @@ -121,6 +125,7 @@ [:exporter-shared-key {:optional true} :string] [:admin-console-shared-key {:optional true} :string] [:nexus-shared-key {:optional true} :string] + [:media-processor-shared-key {:optional true} :string] [:management-api-key {:optional true} :string] [:telemetry-uri {:optional true} :string] @@ -147,6 +152,13 @@ [:imagemagick-width-limit {:optional true} :string] [:imagemagick-height-limit {:optional true} :string] + [:media-processing-service-uri {:optional true} ::sm/uri] + [:media-processing-service-timeout {:optional true} ::sm/int] + + ;; Binfile import limits (PENPOT_BINFILE_IMPORT_*) + [:binfile-import-max-object-size {:optional true} ::sm/int] + [:binfile-import-max-zip-entries {:optional true} ::sm/int] + [:deletion-delay {:optional true} ::ct/duration] [:file-clean-delay {:optional true} ::ct/duration] [:telemetry-enabled {:optional true} ::sm/boolean] @@ -190,6 +202,7 @@ [:quotes-team-access-requests-per-requester {:optional true} ::sm/int] [:quotes-upload-sessions-per-profile {:optional true} ::sm/int] [:quotes-upload-chunks-per-session {:optional true} ::sm/int] + [:quotes-media-storage-bytes-per-team {:optional true} ::sm/int] [:auth-token-cookie-name {:optional true} :string] [:auth-token-cookie-max-age {:optional true} ::ct/duration] diff --git a/backend/src/app/db.clj b/backend/src/app/db.clj index f10b0089a0..8f7719d705 100644 --- a/backend/src/app/db.clj +++ b/backend/src/app/db.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.db (:refer-clojure :exclude [get run!]) @@ -31,8 +31,8 @@ com.zaxxer.hikari.HikariDataSource com.zaxxer.hikari.HikariPoolMXBean com.zaxxer.hikari.metrics.prometheus.PrometheusMetricsTrackerFactory - io.whitfin.siphash.SipHasher - io.whitfin.siphash.SipHasherContainer + io.whitfin.siphash.SipHash + io.whitfin.siphash.SipHashContext java.io.InputStream java.io.OutputStream java.sql.Connection @@ -701,12 +701,12 @@ ;; --- Locks (def ^:private siphash-state - (SipHasher/container - (uuid/get-bytes uuid/zero))) + (SipHash/context + (uuid/get-bytes uuid/zero))) (defn uuid->hash-code [o] - (.hash ^SipHasherContainer siphash-state + (.hash ^SipHashContext siphash-state ^bytes (uuid/get-bytes o))) (defn- xact-check-param diff --git a/backend/src/app/db/sql.clj b/backend/src/app/db/sql.clj index dc0f54a01a..b81e0c3832 100644 --- a/backend/src/app/db/sql.clj +++ b/backend/src/app/db/sql.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.db.sql (:refer-clojure :exclude [update]) diff --git a/backend/src/app/email.clj b/backend/src/app/email.clj index e069b2908b..24b62ebd76 100644 --- a/backend/src/app/email.clj +++ b/backend/src/app/email.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.email "Main api for send emails." @@ -505,13 +505,13 @@ :schema schema:request-file-access)) (def request-file-access-yourpenpot - "File access on Your Penpot request email." + "File access on Personal Projects request email." (template-factory :id ::request-file-access-yourpenpot :schema schema:request-file-access)) (def request-file-access-yourpenpot-view - "File access on Your Penpot view mode request email." + "File access on Personal Projects view mode request email." (template-factory :id ::request-file-access-yourpenpot-view :schema schema:request-file-access)) diff --git a/backend/src/app/email/blacklist.clj b/backend/src/app/email/blacklist.clj index e54d546f4f..b56cfd42be 100644 --- a/backend/src/app/email/blacklist.clj +++ b/backend/src/app/email/blacklist.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.email.blacklist "Email blacklist provider" diff --git a/backend/src/app/email/whitelist.clj b/backend/src/app/email/whitelist.clj index c8c0f5b310..8b08c0c599 100644 --- a/backend/src/app/email/whitelist.clj +++ b/backend/src/app/email/whitelist.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.email.whitelist "Email whitelist provider" diff --git a/backend/src/app/features/fdata.clj b/backend/src/app/features/fdata.clj index 412ca223cf..43484a696b 100644 --- a/backend/src/app/features/fdata.clj +++ b/backend/src/app/features/fdata.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.features.fdata "A `fdata/*` related feature migration helpers" @@ -12,6 +12,7 @@ [app.common.logging :as l] [app.common.schema :as sm] [app.common.time :as ct] + [app.common.types.file :as ctf] [app.common.types.objects-map :as omap] [app.config :as cf] [app.db :as db] @@ -150,6 +151,13 @@ (cond (= backend "storage") + ;; IMPORTANT: we strongly do not reuse the main connection that can + ;; run inside a transaction because the storage upload process can + ;; fail in the middle of uploading and leave garbage on the underlying + ;; backend, if we participate in the main transaction and it aborts + ;; we will lose all registry of the pending to reconcile blobs + ;; what the storage subsystem registers in other parallel + ;; transaction (let [storage (sto/resolve cfg) content (sto/content data) sobject (sto/put-object! storage @@ -159,15 +167,17 @@ :content-type "application/octet-stream" :file-id file-id :id id}) - metadata {:storage-ref-id (:id sobject)} + metadata (-> (:metadata params) + (assoc :storage-ref-id (:id sobject))) params (-> params (assoc :metadata metadata) (assoc :data nil))] (upsert-in-database cfg params)) (= backend "db") - (->> (dissoc params :metadata) - (upsert-in-database cfg)) + (let [metadata (dissoc (:metadata params) :storage-ref-id) + params (assoc params :metadata metadata)] + (upsert-in-database cfg params)) (= backend "legacy-db") (cond @@ -213,18 +223,11 @@ [backend] (or backend (cf/get :file-data-backend))) -(def ^:private schema:metadata - [:map {:title "Metadata"} - [:storage-ref-id {:optional true} ::sm/uuid]]) - -(def decode-metadata-with-schema - (sm/decoder schema:metadata sm/json-transformer)) - (defn decode-metadata [metadata] (some-> metadata (db/decode-json-pgobject) - (decode-metadata-with-schema))) + (ctf/decode-file-metadata))) (def ^:private schema:update-params [:map {:closed true} @@ -232,7 +235,7 @@ [:type [:enum "main" "snapshot" "fragment"]] [:file-id ::sm/uuid] [:backend {:optional true} [:enum "db" "legacy-db" "storage"]] - [:metadata {:optional true} [:maybe schema:metadata]] + [:metadata {:optional true} ctf/schema:file-metadata] [:data {:optional true} bytes?] [:created-at {:optional true} ::ct/inst] [:modified-at {:optional true} [:maybe ::ct/inst]] diff --git a/backend/src/app/features/file_migrations.clj b/backend/src/app/features/file_migrations.clj index 2334cbc121..f7a4ffdcb1 100644 --- a/backend/src/app/features/file_migrations.clj +++ b/backend/src/app/features/file_migrations.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.features.file-migrations "Backend specific code for file migrations. Implemented as permanent feature of files." diff --git a/backend/src/app/features/file_snapshots.clj b/backend/src/app/features/file_snapshots.clj index 2c29b2b5de..9be4b1b572 100644 --- a/backend/src/app/features/file_snapshots.clj +++ b/backend/src/app/features/file_snapshots.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.features.file-snapshots (:require @@ -326,8 +326,11 @@ (let [file (d/update-when row :metadata fdata/decode-metadata) vern (rand-int Integer/MAX_VALUE) + ;; We reuse the main connection here for storage operations + ;; becaue the main operations are touching and we need them + ;; to be atomic with the current transaction storage - (sto/resolve cfg {::db/reuse-conn true}) + (sto/resolve cfg ::db/reuse-conn true) snapshot (get-snapshot cfg file-id snapshot-id)] diff --git a/backend/src/app/features/logical_deletion.clj b/backend/src/app/features/logical_deletion.clj index 0b87006616..5dc80ab4f1 100644 --- a/backend/src/app/features/logical_deletion.clj +++ b/backend/src/app/features/logical_deletion.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.features.logical-deletion "A code related to handle logical deletion mechanism" diff --git a/backend/src/app/graph/arrow.clj b/backend/src/app/graph/arrow.clj new file mode 100644 index 0000000000..55be1b8b64 --- /dev/null +++ b/backend/src/app/graph/arrow.clj @@ -0,0 +1,370 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.arrow + "Bulk Ladybug ingest through in-memory Arrow. + + Rows are built as Arrow `VectorSchemaRoot`s in the JVM's off-heap memory, + handed to Ladybug as a virtual table, and `COPY`d into the real one. No file + is written and no value is rendered as text for the engine to re-parse, so + nothing in this path needs escaping. Arrow carries MAP, STRUCT, fixed-size + arrays and multi-line strings natively. + + The type language is Ladybug's, read recursively by `app.graph.schema.values`; + this namespace adds the matching Arrow `Field` and a writer for each shape. + `values/coerce` shapes a value first — a matrix into six doubles, a colour + into a packed integer — exactly as it does for the Cypher path, so the two + writers cannot disagree. + + Engine facts this file depends on, each verified against lbug 0.19.1: + + - An Arrow table is **not** a `COPY` source identifier, but it *is* a + MATCH-able node label: `COPY T FROM (MATCH (n:stg) RETURN n.a AS a, …)`. + - A MAP vector's `entries` child struct must be non-nullable, and + `MapVector/getWriter` silently promotes it to a sparse union — so map + vectors are built from an explicit `Field` and filled child-first. + - Ladybug quotes the column and table names it interpolates into the staged + table's DDL, and does not quote a STRUCT member name. So a top-level field + arrives plain and a struct member whose name is a reserved word (`column`) + arrives backticked. + - `createArrowRelTable` resolves a UUID-keyed endpoint only from a + `FixedSizeBinary(16)` column carrying the `arrow.uuid` extension, so edges + are staged as a node table and joined by the `COPY` subquery instead." + (:require + [app.common.json :as json] + [app.graph.ladybug :as ladybug] + [app.graph.schema.nodes :as nodes] + [app.graph.schema.values :as values] + [clojure.string :as str]) + (:import + com.ladybugdb.Connection + com.ladybugdb.QueryResult + java.nio.charset.StandardCharsets + java.util.ArrayList + java.util.List + org.apache.arrow.memory.BufferAllocator + org.apache.arrow.memory.RootAllocator + org.apache.arrow.vector.BigIntVector + org.apache.arrow.vector.BitVector + org.apache.arrow.vector.complex.ListVector + org.apache.arrow.vector.complex.MapVector + org.apache.arrow.vector.complex.StructVector + org.apache.arrow.vector.FieldVector + org.apache.arrow.vector.Float8Vector + org.apache.arrow.vector.TimeStampMicroVector + org.apache.arrow.vector.types.FloatingPointPrecision + org.apache.arrow.vector.types.pojo.ArrowType$Bool + org.apache.arrow.vector.types.pojo.ArrowType$FloatingPoint + org.apache.arrow.vector.types.pojo.ArrowType$Int + org.apache.arrow.vector.types.pojo.ArrowType$List + org.apache.arrow.vector.types.pojo.ArrowType$Map + org.apache.arrow.vector.types.pojo.ArrowType$Struct + org.apache.arrow.vector.types.pojo.ArrowType$Timestamp + org.apache.arrow.vector.types.pojo.ArrowType$Utf8 + org.apache.arrow.vector.types.pojo.Field + org.apache.arrow.vector.types.pojo.FieldType + org.apache.arrow.vector.types.pojo.Schema + org.apache.arrow.vector.types.TimeUnit + org.apache.arrow.vector.UInt4Vector + org.apache.arrow.vector.VarCharVector + org.apache.arrow.vector.VectorSchemaRoot)) + +(set! *warn-on-reflection* true) + +;; --------------------------------------------------------------- allocator + +(defn with-allocator! + "Invoke `(f allocator)` with a fresh Arrow `RootAllocator`. + + The allocator must outlive the Ladybug connection, because Ladybug releases + its references to the staged buffers only when the Arrow tables are dropped — + which happens on connection close at the latest. Closing it first surfaces as + `IllegalStateException: Memory was leaked`, *thrown while unwinding*, which + hides whatever actually failed. Any diagnostic here must catch inside this + scope." + [f] + (with-open [allocator (RootAllocator.)] + (f allocator))) + +;; ------------------------------------------------------ Ladybug type → Field + +(def ^:private scalar-arrow-type + "Ladybug scalar → Arrow type. `UUID` and `JSON` ride as UTF-8: Ladybug + accepts a string into either column and does the conversion itself, which is + cheaper than teaching this side two more binary layouts." + {"STRING" #(ArrowType$Utf8.) + "UUID" #(ArrowType$Utf8.) + "JSON" #(ArrowType$Utf8.) + "INT64" #(ArrowType$Int. 64 true) + "UINT32" #(ArrowType$Int. 32 false) + "DOUBLE" #(ArrowType$FloatingPoint. FloatingPointPrecision/DOUBLE) + "BOOLEAN" #(ArrowType$Bool.) + "TIMESTAMP" #(ArrowType$Timestamp. TimeUnit/MICROSECOND nil)}) + +(defn column-field + "Arrow `Field` for a column of `ladybug-type`, recursively. + + `nullable?` is false only where Arrow's own invariants demand it — a MAP's + `entries` struct and its key." + (^Field [^String field-name ladybug-type] + (column-field field-name ladybug-type true)) + (^Field [^String field-name ladybug-type nullable?] + (cond + ;; A list first: `STRUCT(…)[]` starts with `STRUCT(` but is a list of them. + (ladybug/list-type? ladybug-type) + (Field. field-name (FieldType. nullable? (ArrowType$List.) nil) + [(column-field "item" (values/list-element ladybug-type))]) + + (ladybug/map-type? ladybug-type) + (let [[key-type value-type] (values/map-types ladybug-type)] + (Field. field-name (FieldType. nullable? (ArrowType$Map. false) nil) + [(Field. "entries" (FieldType. false (ArrowType$Struct.) nil) + [(column-field "key" key-type false) + (column-field "value" value-type)])])) + + (ladybug/struct-type? ladybug-type) + (Field. field-name (FieldType. nullable? (ArrowType$Struct.) nil) + ;; Backticks kept: Ladybug quotes none of these when it names the + ;; staged struct's fields, so `column` has to arrive quoted. + (mapv (fn [[field field-type]] (column-field field field-type)) + (values/struct-fields-quoted ladybug-type))) + + :else + (if-let [mk (get scalar-arrow-type ladybug-type)] + (Field. field-name (FieldType. nullable? (mk) nil) nil) + (throw (ex-info (str "no Arrow mapping for Ladybug type: " ladybug-type) + {:ladybug-type ladybug-type})))))) + +;; ------------------------------------------------------------------- writer + +(defn- utf8 + ^bytes [v] + (.getBytes (if (keyword? v) (name v) (str v)) StandardCharsets/UTF_8)) + +(defn- epoch-micros + ^long [v] + (let [^java.time.Instant inst + (cond + (instance? java.time.Instant v) v + (instance? java.util.Date v) (.toInstant ^java.util.Date v) + :else (java.time.Instant/parse (str v)))] + (+ (* (.getEpochSecond inst) 1000000) (long (quot (.getNano inst) 1000))))) + +(defn- write-scalar! + [^FieldVector fv ladybug-type ^long idx v] + (case ladybug-type + ("STRING" "UUID") (.setSafe ^VarCharVector fv idx (utf8 v)) + ;; A JSON column holds JSON, not a Clojure value's print form: `str` on a + ;; map yields `{:fill-color "#000000"}`, which is EDN and which every + ;; consumer of `fills`, `content` or `position_data` would fail to parse. + ;; Same encoder the Cypher path uses (`app.graph.ladybug/format-json`). + "JSON" (.setSafe ^VarCharVector fv idx + (.getBytes ^String (json/encode v) + StandardCharsets/UTF_8)) + "INT64" (.setSafe ^BigIntVector fv idx (long v)) + "UINT32" (.setSafe ^UInt4Vector fv idx (unchecked-int (long v))) + "DOUBLE" (.setSafe ^Float8Vector fv idx (double v)) + "BOOLEAN" (.setSafe ^BitVector fv idx (if v 1 0)) + "TIMESTAMP" (.setSafe ^TimeStampMicroVector fv idx (epoch-micros v)) + (throw (ex-info (str "no Arrow writer for Ladybug type: " ladybug-type) + {:ladybug-type ladybug-type})))) + +(defn write-value! + "Write already-coerced `v` into `fv` at `idx`, per `ladybug-type`. + + `map-key-fn` renders the keys of a `MAP(STRING, …)`, for the same reason + `app.graph.ladybug/format-typed-value` takes one: the right spelling is a + property of the column, not of the writer." + ;; `idx` is deliberately unhinted: Clojure only accepts primitive args on fns + ;; of four or fewer, and the map-key renderer has to travel with the value. + [^FieldVector fv ladybug-type idx v map-key-fn] + (if (nil? v) + (.setNull fv (int idx)) + (cond + (ladybug/list-type? ladybug-type) + (let [^ListVector lv fv + child (.getDataVector lv) + element-type (values/list-element ladybug-type) + elements (vec (if (or (sequential? v) (set? v)) v [v])) + start (.startNewValue lv (int idx))] + (dotimes [i (count elements)] + (write-value! child element-type (+ start i) (nth elements i) map-key-fn)) + (.endValue lv (int idx) (count elements))) + + (ladybug/map-type? ladybug-type) + (let [^MapVector mv fv + ^StructVector entries (.getDataVector mv) + [key-type value-type] (values/map-types ladybug-type) + key-vec (.getChild entries "key") + value-vec (.getChild entries "value") + render-key (if (and map-key-fn (= "STRING" key-type)) map-key-fn identity) + pairs (vec (seq v)) + start (.startNewValue mv (int idx))] + (dotimes [i (count pairs)] + (let [[k mv'] (nth pairs i) + at (+ start i)] + ;; The entries struct is non-nullable: every slot must be defined. + (.setIndexDefined entries (int at)) + (write-value! key-vec key-type at (render-key k) nil) + (write-value! value-vec value-type at mv' map-key-fn))) + (.endValue mv (int idx) (count pairs))) + + (ladybug/struct-type? ladybug-type) + (let [^StructVector sv fv] + (.setIndexDefined sv (int idx)) + (doseq [[quoted-field field-type] (values/struct-fields-quoted ladybug-type)] + ;; The child is named with its backticks; the coerced value is keyed + ;; without them. + (write-value! (.getChild sv quoted-field) field-type idx + (get v (str/replace quoted-field "`" "")) map-key-fn))) + + :else + (write-scalar! fv ladybug-type (long idx) v)))) + +;; ------------------------------------------------------------------ batches + +(defn- fill-vector! + [^VectorSchemaRoot root ^String field-name ladybug-type rows value-fn map-key-fn] + (let [^FieldVector fv (.getVector root field-name)] + (.allocateNew fv) + (dotimes [i (count rows)] + (write-value! fv ladybug-type i + (values/coerce ladybug-type (value-fn (nth rows i))) + map-key-fn)) + (.setValueCount fv (count rows)))) + +(defn- node-batch + "One `VectorSchemaRoot` holding every projected row of `table`. + + Fields carry the plain column name. Ladybug quotes every identifier it + interpolates into the staged table's DDL, so a name that is a reserved word + (`Page.index`, `Document.options`) arrives unquoted and a name arriving + pre-quoted comes out doubly backticked and fails to parse. The `COPY` + projection below is Cypher, not DDL, so it quotes the same names itself." + ^VectorSchemaRoot [^BufferAllocator allocator table rows] + (let [columns (nodes/column-keys table) + fields (mapv (fn [k] (column-field (nodes/column-name table k) + (nodes/column-ladybug-type table k))) + columns) + root (VectorSchemaRoot/create (Schema. ^List fields) allocator)] + (doseq [k columns] + (fill-vector! root (nodes/column-name table k) + (nodes/column-ladybug-type table k) + rows #(get % k) (nodes/column-map-key-fn table k))) + (.setRowCount root (count rows)) + root)) + +(def ^:private edge-fields + "Edge staging columns. `id` is the staging table's own key — Ladybug wants a + first column to key the virtual table on — and `from`/`to` land as STRING, + hence the cast in the join." + [(Field. "id" (FieldType. true (ArrowType$Utf8.) nil) nil) + (Field. "from" (FieldType. true (ArrowType$Utf8.) nil) nil) + (Field. "to" (FieldType. true (ArrowType$Utf8.) nil) nil) + (Field. "position" (FieldType. true (ArrowType$Int. 64 true) nil) nil)]) + +(defn- edge-batch + ^VectorSchemaRoot [^BufferAllocator allocator edges] + (let [root (VectorSchemaRoot/create (Schema. ^List edge-fields) allocator) + ^VarCharVector iv (.getVector root "id") + ^VarCharVector fv (.getVector root "from") + ^VarCharVector tv (.getVector root "to") + ^BigIntVector pv (.getVector root "position") + n (count edges)] + (doseq [^FieldVector v [iv fv tv pv]] (.allocateNew v)) + (dotimes [i n] + (let [{:keys [from-id to-id position]} (nth edges i)] + (.setSafe iv i (utf8 i)) + (.setSafe fv i (utf8 from-id)) + (.setSafe tv i (utf8 to-id)) + (if (nil? position) (.setNull pv i) (.setSafe pv i (long position))))) + (doseq [^FieldVector v [iv fv tv pv]] (.setValueCount v n)) + (.setRowCount root n) + root)) + +;; ------------------------------------------------------------------ staging + +(defn- batches + ^List [^VectorSchemaRoot root] + (doto (ArrayList.) (.add root))) + +(defn- check! + [^QueryResult result hint data] + (when-not (.isSuccess result) + (throw (ex-info (str hint ": " (.getErrorMessage result)) + (assoc data :err (.getErrorMessage result)))))) + +(defn- with-staged-table! + "Create Arrow table `staging-name` from `root`, run `(f)`, always drop it." + [^Connection conn ^BufferAllocator allocator ^String staging-name + ^VectorSchemaRoot root data f] + (try + (with-open [^QueryResult r (.createArrowTable conn staging-name (batches root) allocator)] + (check! r "createArrowTable failed" data)) + (f) + (finally + ;; Dropped even on failure: the staged buffers stay referenced by Ladybug + ;; until it is, and the allocator's leak check fires on close otherwise. + (try (.close ^QueryResult (.dropArrowTable conn staging-name)) + (catch Throwable _ nil))))) + +(defn- copy-node-table! + [^Connection conn table ^String staging-name] + (let [projection (str/join ", " (for [k (nodes/column-keys table) + :let [c (nodes/cypher-property-key table k)]] + (str "n." c " AS " c))) + statement (str "COPY `" table "` FROM (MATCH (n:" staging-name ") " + "RETURN " projection ");")] + (with-open [^QueryResult r (.query conn statement)] + (check! r (str "COPY node table failed: " table) + {:table table :statement statement})))) + +(defn- copy-edge-group! + "Load one FROM/TO pair of `IsChildOf`. + + `createArrowRelTable` is unusable here — it cannot resolve endpoints against a + UUID-keyed node table — so the edge list is staged as a node table and the + endpoints are resolved by the subquery. The `WHERE` is clause-level because + this dialect prohibits an inline pattern `WHERE`, and both sides are pinned by + label so the join cannot reach outside the pair." + [^Connection conn from-table to-table ^String staging-name] + (let [statement (str "COPY `IsChildOf` FROM (" + "MATCH (e:" staging-name "), " + "(a:" (nodes/match-label from-table) "), " + "(b:" (nodes/match-label to-table) ") " + "WHERE a.id = cast(e.from AS UUID) " + "AND b.id = cast(e.to AS UUID) " + "RETURN a.id, b.id, e.position) " + "(from='" from-table "', to='" to-table "');")] + (with-open [^QueryResult r (.query conn statement)] + (check! r (str "COPY edge group failed: " from-table " -> " to-table) + {:from-table from-table :to-table to-table :statement statement})))) + +(defn- staging-name + [prefix & parts] + (str/replace (str/join "_" (cons (str "stg_" prefix) parts)) #"[^A-Za-z0-9_]" "_")) + +;; --------------------------------------------------------------------- load + +(defn load-projection! + "Load projected nodes and edges into an open Ladybug connection. + + `allocator` must outlive `conn` — see `with-allocator!`." + [^Connection conn {:keys [nodes edges]} ^BufferAllocator allocator] + (doseq [[table rows] (sort-by key nodes) + :when (seq rows)] + (let [name (staging-name "node" table)] + (with-open [root (node-batch allocator table rows)] + (with-staged-table! conn allocator name root {:table table} + #(copy-node-table! conn table name))))) + (doseq [[[from-table to-table] group] + (sort-by key (group-by (juxt :from-table :to-table) edges)) + :when (seq group)] + (let [name (staging-name "edge" from-table to-table)] + (with-open [root (edge-batch allocator group)] + (with-staged-table! conn allocator name root + {:from-table from-table :to-table to-table} + #(copy-edge-group! conn from-table to-table name)))))) diff --git a/backend/src/app/graph/debug.clj b/backend/src/app/graph/debug.clj new file mode 100644 index 0000000000..277b1ae177 --- /dev/null +++ b/backend/src/app/graph/debug.clj @@ -0,0 +1,383 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.debug + "In-memory Ladybug sessions for the debug graph console." + (:require + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.common.time :as ct] + [app.graph.ingest :as graph.ingest] + [app.graph.ladybug :as ladybug] + [app.graph.schema.nodes :as nodes] + [app.graph.sync :as graph.sync] + [app.msgbus :as mbus] + [clojure.java.io :as io] + [clojure.string :as str] + [promesa.exec.csp :as sp]) + (:import + com.ladybugdb.Connection + com.ladybugdb.Database)) + +(set! *warn-on-reflection* true) + +(def default-query + "Default console query, written to be self-explanatory in the textarea. + The `filter_*` columns carry node ids for the graph-view result filter; + the results table hides them (see `hide-filter-columns` and the + template's `renderQueryOutput`)." + (str "MATCH (s)-[r]->(t)\n" + "// WHERE some condition\n" + "RETURN label(s) AS src, s.name,\n" + " label(r) AS rel,\n" + " t.name, label(t) AS tgt,\n" + "\n" + "// filter_* columns omitted from table; these needed for graph view\n" + "s.id AS filter_src_id, t.id AS filter_tgt_id;")) + +(defonce ^:private sessions + (atom {})) + +(defn- session-key + [profile-id] + (str profile-id)) + +(defn- destroy-session! + [{:keys [conn db sync-ch msgbus]}] + (when sync-ch + (sp/close! sync-ch) + (when msgbus + (mbus/purge! msgbus [sync-ch]))) + (when conn + (ex/ignoring (.close ^Connection conn))) + (when db + (ex/ignoring (.close ^Database db)))) + +(defn- slim-ingest-meta + "Drop full projection rows from session meta. + + `build-index` needs `:nodes`/`:edges` once; keeping them in the session + duplicates the entire graph on the JVM heap for every Load." + [meta] + (update meta :projection #(select-keys % [:stats]))) + +(defn- format-cell + [value] + (cond + (nil? value) "NULL" + (string? value) value + :else (str value))) + +(defn- format-query-result + [{:keys [columns rows truncated?]}] + {:columns (mapv str columns) + :rows (mapv (fn [row] + (mapv format-cell row)) + rows) + :truncated? truncated? + :row-count (count rows)}) + +(defn- apply-file-change! + [conn profile-id {:keys [changes revn file-id]}] + (try + (some-> (get @sessions (session-key profile-id)) + (as-> current + (when (= file-id (:file-id current)) + (let [lock (:lock current) + result (locking lock + (graph.sync/apply-changes! + conn (:index current) changes revn)) + sync-at (ct/now)] + (swap! sessions assoc-in [(session-key profile-id) :index] + (:index result)) + (swap! sessions update-in [(session-key profile-id) :meta] + (fn [meta] + (cond-> (-> meta + (update :sync dissoc :error) + (assoc-in [:sync :last-at] sync-at) + (assoc-in [:sync :last-applied] (:applied result)) + (assoc-in [:sync :last-skipped] (:skipped result))) + (seq (:applied result)) + (assoc :revn (:revn result))))) + (when (seq (:skipped result)) + (l/dbg :hint "graph sync skipped changes" + :file-id (str file-id) + :revn revn + :skipped (:skipped result))))))) + (catch Throwable cause + (l/wrn :hint "graph sync failed" + :file-id (str file-id) + :cause cause) + (swap! sessions assoc-in [(session-key profile-id) :meta :sync :error] + (ex-message cause))))) + +(defn- start-sync-loop! + [{:keys [conn profile-id file-id] :as session}] + (if-let [msgbus (:msgbus session)] + (let [sync-ch (sp/chan :buf (sp/dropping-buffer 64))] + (mbus/sub! msgbus :topic file-id :chan sync-ch) + ;; Recur ONLY while the channel is open. A bare `(recur)` after + ;; `take!` returns nil would spin forever and pin this Connection + ;; (and its Ladybug Database native memory) across every Load. + (sp/go-loop [] + (when-let [message (sp/take! sync-ch)] + (when (= :file-change (:type message)) + (apply-file-change! conn profile-id message)) + (recur))) + (assoc session :sync-ch sync-ch)) + session)) + +(defn session-info + "Return a public view of the current session for `profile-id`, if any." + [profile-id] + (when-let [{:keys [file-id meta loaded-at index]} (get @sessions (session-key profile-id))] + {:file-id file-id + :name (:name meta) + :revn (:revn meta) + :graph-revn (:revn index) + :schema-version (:schema-version meta) + :projection (:projection meta) + :sync (:sync meta) + :loaded-at (ct/format-inst loaded-at :iso)})) + +(defn sync-status + "Return incremental sync status for the active session." + [profile-id] + (when-let [session (get @sessions (session-key profile-id))] + (let [{:keys [file-id meta index loaded-at]} session] + {:file-id file-id + :revn (:revn meta) + :graph-revn (:revn index) + :sync (:sync meta) + :loaded-at (ct/format-inst loaded-at :iso)}))) + +(defn unload-session! + "Close and discard the in-memory graph for `profile-id`." + [profile-id] + (when-let [session (get @sessions (session-key profile-id))] + (destroy-session! session)) + (swap! sessions dissoc (session-key profile-id))) + +(defn load-session! + "Ingest `file-id` into a new in-memory Ladybug database for `profile-id`." + [cfg profile-id file-id] + (unload-session! profile-id) + (let [^Database db (Database.) + ^Connection conn (Connection. db) + msgbus (::mbus/msgbus cfg)] + (.setQueryTimeout conn 0) + (ladybug/ensure-extensions! conn) + (try + (let [meta (graph.ingest/ingest-on-connection! cfg conn file-id + :db-path ":memory:" + :skip-stats? true + :skip-validation? true) + index (graph.sync/build-index file-id (:revn meta) (:projection meta)) + ;; Discard projection rows after indexing — they are only needed + ;; to seed the sync index and would otherwise leak heap on each Load. + meta (slim-ingest-meta meta) + session + ;; :lock serializes access to the shared Connection between the + ;; msgbus sync loop (writes) and HTTP handlers (reads); the Java + ;; binding gives no thread-safety guarantee for one Connection. + (-> {:db db + :conn conn + :lock (Object.) + :file-id file-id + :meta meta + :index index + :msgbus msgbus + :profile-id profile-id + :loaded-at (ct/now)} + start-sync-loop!)] + (swap! sessions assoc (session-key profile-id) session) + meta) + (catch Throwable cause + (destroy-session! {:conn conn :db db :msgbus msgbus}) + (throw cause))))) + +(defn query-session! + "Run a read-only `statement` against the in-memory graph for `profile-id`. + + The statement is bound against the live schema before it runs, so a query + naming a table or a property that does not exist reports the binder's own + message and executes nothing. The engine's read/write analysis then decides + whether it may run at all: the console is an inspection surface, and a + session graph is rebuilt from the file by Reload, so a mutation from here + would produce a graph no rebuild reproduces." + [profile-id statement] + (when (str/blank? statement) + (ex/raise :type :validation + :code :missing-query + :hint "cypher query is required")) + (if-let [{:keys [conn lock]} (get @sessions (session-key profile-id))] + (locking lock + (let [{:keys [ok? error read-only?]} (ladybug/validate-on-connection! conn statement)] + (when-not ok? + (ex/raise :type :validation + :code :graph-query-invalid + :hint error)) + (when-not read-only? + (ex/raise :type :validation + :code :graph-query-not-read-only + :hint "the graph console runs read-only queries")) + (-> (ladybug/query-on-connection! conn statement) + format-query-result))) + (ex/raise :type :not-found + :code :graph-session-not-loaded + :hint "load a file graph before running queries"))) + +(def ^:private export-max-rows + "Row cap for graph-view export queries; far above expected per-file node + and edge counts. `:truncated` in the export signals when it was hit." + 100000) + +(defn- export-nodes + [conn] + (reduce + (fn [acc {:keys [table]}] + (let [stmt (str "MATCH (n:" (nodes/match-label table) + ") RETURN n.id AS id, n.name AS name;") + {:keys [rows truncated?]} + (ladybug/query-on-connection! conn stmt :max-rows export-max-rows)] + (-> acc + (update :nodes into + (map (fn [[id label]] + {:id (str id) :label (str label) :table table})) + rows) + (update :truncated? #(or % truncated?))))) + {:nodes [] :truncated? false} + nodes/node-types)) + +(defn rel-tables + "Every relationship table in the open database, with whether it carries a + `position` property. + + Read from the catalog rather than listed here, so a newly ported transform's + rel table appears in the graph view without the console being told about it." + [conn] + (for [[table] (:rows (ladybug/query-on-connection! + conn "CALL show_tables() WHERE type = 'REL' RETURN name;" + :max-rows 1000)) + :let [props (->> (ladybug/query-on-connection! + conn (str "CALL table_info('" table "') RETURN *;") + :max-rows 1000) + :rows + (into #{} (map (comp str second))))]] + {:table table :position? (contains? props "position")})) + +(defn- export-edges + [conn] + (reduce + (fn [acc {:keys [table position?]}] + (let [stmt (str "MATCH (a)-[r:`" table "`]->(b) " + "RETURN a.id AS source, b.id AS target, " + (if position? "r.position" "NULL") " AS position, " + "'" table "' AS rel;") + {:keys [rows truncated?]} + (ladybug/query-on-connection! conn stmt :max-rows export-max-rows)] + (-> acc + (update :edges into + (map (fn [[source target position rel]] + (cond-> {:source (str source) + :target (str target) + :rel (str rel)} + (some? position) (assoc :position position)))) + rows) + (update :truncated? #(or % truncated?))))) + {:edges [] :truncated? false} + (rel-tables conn))) + +(defn- bm-usage-bytes + "Buffer-manager memory in use by this session's in-memory database + (`CALL bm_info()` → [mem_limit mem_usage]); nil if the call fails." + [conn] + (ex/ignoring + (-> (ladybug/query-on-connection! conn "CALL bm_info() RETURN *;" :max-rows 1) + :rows first second))) + +(defn export-graph-data! + "Export the node/edge inventory of the in-memory graph for `profile-id` + as plain data for the debug graph view. Returns nil when no session is + loaded. Queries the Ladybug database (not the sync index) so the view + reflects actual DB state, including drift." + [profile-id] + (when-let [{:keys [conn lock file-id index]} (get @sessions (session-key profile-id))] + (locking lock + (let [{:keys [nodes] nodes-truncated? :truncated?} (export-nodes conn) + {:keys [edges] edges-truncated? :truncated?} (export-edges conn)] + {:file-id (str file-id) + :revn (:revn index) + :truncated (boolean (or nodes-truncated? edges-truncated?)) + :bm-bytes (bm-usage-bytes conn) + :nodes nodes + :edges edges})))) + +(defn- delete-tree! + [^java.io.File file] + (when (.exists file) + (doseq [f (reverse (file-seq file))] + (.delete ^java.io.File f)))) + +(defn export-session-database! + "Materialize the in-memory session graph of `profile-id` as a `.lbug` file. + + The console's graph is in-memory and live-synced, so it can differ from a + fresh projection of the same file — which is exactly when someone wants to + take it away and query it elsewhere. There is no \"save this database\" + primitive, so the transfer goes through Ladybug's `EXPORT DATABASE` (Parquet + per table) into a fresh on-disk database via `IMPORT DATABASE`. + + Note the round trip drops table comments. Nothing in the graph is addressed + by a table comment: every table is resolved by name, so the loss costs + nothing. + + Returns the path of the written database, or nil when no session is loaded. + The caller owns the file and must delete it once streamed." + [profile-id] + (when-let [{:keys [conn lock file-id]} (get @sessions (session-key profile-id))] + (let [stamp (System/nanoTime) + staging (io/file (System/getProperty "java.io.tmpdir") + (str "penpot-graph-session-" file-id "-" stamp)) + db-path (str (io/file (System/getProperty "java.io.tmpdir") + (str file-id "-session-" stamp ".lbug")))] + (try + (locking lock + (ladybug/exec-on-connection! + conn [(str "EXPORT DATABASE '" (.getAbsolutePath staging) + "' (format='parquet');")])) + (ladybug/with-connection! db-path + (fn [target] + (ladybug/exec-on-connection! + target [(str "IMPORT DATABASE '" (.getAbsolutePath staging) "';") + "CHECKPOINT;"]))) + db-path + (finally + (delete-tree! staging)))))) + +(defn- hide-filter-columns + "Drop `filter_*` columns from a query result before HTML table render; + they exist to feed node ids to the graph-view filter, not for reading. + The JSON response path keeps the full result." + [{:keys [columns rows] :as result}] + (let [idxs (vec (keep-indexed + (fn [i c] (when-not (str/starts-with? (str c) "filter_") i)) + columns))] + (if (or (empty? idxs) (= (count idxs) (count columns))) + result + (assoc result + :columns (mapv (vec columns) idxs) + :rows (mapv (fn [row] (mapv (vec row) idxs)) rows))))) + +(defn console-context + "Build template data for the graph debug console page." + [profile-id & {:keys [query query-result error message]}] + {:session (session-info profile-id) + :query (or query default-query) + :query-result (some-> query-result hide-filter-columns) + :error error + :message message + :default-query default-query}) diff --git a/backend/src/app/graph/ingest.clj b/backend/src/app/graph/ingest.clj new file mode 100644 index 0000000000..af0644ee4d --- /dev/null +++ b/backend/src/app/graph/ingest.clj @@ -0,0 +1,106 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.ingest + "Penpot file -> Ladybug graph projection." + (:require + [app.binfile.common :as bfc] + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.common.types.file :as ctf] + [app.db :as db] + [app.graph.arrow :as graph.arrow] + [app.graph.ladybug :as ladybug] + [app.graph.meta :as graph.meta] + [app.graph.projection.document :as projection.document] + [app.graph.projection.transforms :as projection.transforms] + [app.graph.schema :as schema] + [app.graph.stats :as stats] + [app.srepl.helpers :as h]) + (:import + com.ladybugdb.Connection + org.apache.arrow.memory.BufferAllocator)) + +(defn- fetch-file! + [system file-id] + (let [file-id (h/parse-uuid file-id) + file (db/run! system #(bfc/get-file % file-id :realize? true))] + (when-not file + (ex/raise :type :not-found + :code :file-not-found + :file-id (str file-id))) + (when-not (:data file) + (ex/raise :type :validation + :code :file-without-data + :hint "file has no data to project" + :file-id (str file-id))) + [file-id file])) + +(defn- ingest-on-connection*! + [system ^Connection conn file-id ^BufferAllocator allocator + {:keys [db-path skip-stats? skip-validation?] :or {skip-stats? true}}] + (let [[file-id file] (fetch-file! system file-id) + db-path (or db-path (ladybug/db-path-for-file file-id)) + data (:data file)] + (when-not skip-validation? + (ctf/check-file-data data)) + (l/inf :hint "graph ingest" + :file-id (str file-id) + :revn (:revn file) + :db-path db-path + :schema schema/schema-version) + (let [ddl (schema/ddl-statements) + {:keys [nodes edges stats]} + (projection.document/projection-data data file)] + (ladybug/exec-on-connection! conn ddl) + (graph.arrow/load-projection! conn {:nodes nodes :edges edges} allocator) + (ladybug/exec-on-connection! conn ["CHECKPOINT;"]) + (let [transforms (projection.transforms/apply-transforms! system conn data file)] + ;; Written last: its presence doubles as the build-complete marker. + (graph.meta/write! conn {:file-id file-id + :revn (:revn file)}) + {:file-id file-id + :revn (:revn file) + :name (or (:name data) (:name file)) + :db-path db-path + :schema-version schema/schema-version + :projection {:stats stats + :nodes nodes + :edges edges} + :transforms transforms + :stats (when-not skip-stats? + (stats/summarize-connection conn))})))) + +(defn ingest-on-connection! + "Project `file-id` into an already open Ladybug `conn`. + + Takes an `:arrow-alloc` when the caller already owns one; otherwise it makes + a short-lived allocator around this call. A caller that opened the connection + itself should pass its own, because the allocator has to be closed *after* + the connection — see `app.graph.arrow/with-allocator!`." + [system ^Connection conn file-id & {:keys [arrow-alloc] :as opts}] + (if arrow-alloc + (ingest-on-connection*! system conn file-id arrow-alloc opts) + (graph.arrow/with-allocator! + (fn [allocator] (ingest-on-connection*! system conn file-id allocator opts))))) + +(defn ingest-file! + [system file-id & {:keys [db-path reset-db? skip-stats? skip-validation?] + :or {reset-db? true}}] + (let [db-path (or db-path (ladybug/db-path-for-file (h/parse-uuid file-id)))] + (when reset-db? + (ladybug/reset-db-path! db-path)) + ;; Allocator outermost: Ladybug holds the staged Arrow buffers until its + ;; tables are dropped, which is no later than connection close, so the + ;; allocator must be closed after the connection and the database. + (graph.arrow/with-allocator! + (fn [allocator] + (ladybug/with-connection! db-path + (fn [conn] + (ingest-on-connection*! system conn file-id allocator + {:db-path db-path + :skip-stats? skip-stats? + :skip-validation? skip-validation?}))))))) diff --git a/backend/src/app/graph/ladybug.clj b/backend/src/app/graph/ladybug.clj new file mode 100644 index 0000000000..81d117c26e --- /dev/null +++ b/backend/src/app/graph/ladybug.clj @@ -0,0 +1,504 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.ladybug + "Ladybug access layer for graph-backed Penpot. + + Uses the embedded Java API (`com.ladybugdb/lbug`)." + (:require + [app.common.exceptions :as ex] + [app.common.json :as json] + [app.graph.schema.values :as values] + [clojure.string :as str] + [datoteka.fs :as fs]) + (:import + com.ladybugdb.Connection + com.ladybugdb.Database + com.ladybugdb.FlatTuple + com.ladybugdb.PreparedStatement + com.ladybugdb.QueryResult + com.ladybugdb.Value)) + +(set! *warn-on-reflection* true) + +(defn default-graph-dir + [] + (or (System/getenv "PENPOT_GRAPH_DIR") "/tmp/penpot-graph")) + +(defn db-path-for-file + [file-id] + (str (fs/path (default-graph-dir) (str file-id ".lbug")))) + +(defn- memory-db-path? + [db-path] + (= db-path ":memory:")) + +(defn reset-db-path! + [db-path] + (when-not (memory-db-path? db-path) + (when (fs/exists? db-path) + (fs/delete db-path)))) + +(defn escape-cypher-string + [s] + (-> (str s) + (str/replace "\\" "\\\\") + (str/replace "'" "\\'"))) + +(defn format-uuid + [id] + (str "uuid('" (str id) "')")) + +(defn format-string + [s] + (str "'" (escape-cypher-string s) "'")) + +(defn format-int + [n] + (str (long n))) + +(defn format-number + [n] + (if (== n (long n)) + (format-int n) + (str (double n)))) + +(defn format-json + [v] + (str "json('" (escape-cypher-string (json/encode v)) "')")) + +(defn format-timestamp + "Ladybug TIMESTAMP literal of the form `timestamp('<ISO-8601 instant>')`." + [v] + (let [s (cond + (instance? java.time.Instant v) + (.toString ^java.time.Instant v) + + (instance? java.util.Date v) + (.toString (.toInstant ^java.util.Date v)) + + (string? v) + v + + :else + (str v))] + (str "timestamp('" (escape-cypher-string s) "')"))) + +(defn format-value + [v] + (cond + (nil? v) "NULL" + (uuid? v) (format-uuid v) + (instance? java.time.Instant v) (format-timestamp v) + (instance? java.util.Date v) (format-timestamp v) + (string? v) (format-string v) + (number? v) (format-number v) + (boolean? v) (if v "true" "false") + (keyword? v) (format-string (name v)) + (map? v) (format-json v) + (coll? v) (format-json v) + :else (format-string (str v)))) + +(defn map-type? + "Is `ladybug-type` a MAP column?" + [ladybug-type] + (and (string? ladybug-type) + (str/starts-with? ladybug-type "MAP(") + (not (str/ends-with? ladybug-type "]")))) + +(defn list-type? + "Is this a list or fixed-size array type? Checked before MAP and STRUCT, + since `STRUCT(…)[]` starts with `STRUCT(` but is a list of them." + [ladybug-type] + (and (string? ladybug-type) + (some? (re-matches #".+\[\d*\]$" ladybug-type)))) + +(defn struct-type? + [ladybug-type] + (and (string? ladybug-type) + (str/starts-with? ladybug-type "STRUCT(") + (not (list-type? ladybug-type)))) + +(declare format-typed-value) + +(defn- format-typed-list + "Cypher LIST literal, elements formatted by the element type. + + Handles `T[]` and the fixed-size `T[n]` alike: the size constrains the column, + not the literal." + [ladybug-type v] + (let [element (second (re-matches #"(.+?)\[\d*\]$" ladybug-type)) + elems (if (or (sequential? v) (set? v)) (seq v) [v])] + (str "[" (str/join ", " (map #(format-typed-value element %) elems)) "]"))) + +(defn- format-struct + "Cypher STRUCT literal, `{field: value, …}`. + + *Every* declared field is emitted, NULL where the value has none: a struct + literal's type is its field list, so omitting a field yields a different type + and Ladybug refuses the implicit cast (`STRUCT(m2 DOUBLE, m4 DOUBLE)` cannot + be assigned to `STRUCT(m1 …, m2 …, m3 …, m4 …)`). Penpot's layout margins are + exactly that case — a shape sets only the sides it overrides." + [ladybug-type v] + (let [fields (values/struct-fields ladybug-type)] + (str "{" + (str/join ", " + (for [[field field-type] fields + :let [fv (get v field)]] + ;; Backticked for the same reason as in the DDL: a field + ;; named `column` is a keyword and will not parse bare. + ;; A bare NULL is typed STRING, which changes the struct's + ;; type as surely as omitting the field would, so absent + ;; fields get a NULL cast to their declared type. + (str "`" field "`: " + (if (nil? fv) + (str "cast(NULL, '" field-type "')") + (format-typed-value field-type fv))))) + "}"))) + +(defn format-typed-value + "Cypher literal for `v` in a column of `ladybug-type`. + + Recursive over the type language, because the types are: a + `MAP(UUID, STRUCT(…))` needs its keys, its fields and each field's own type + honoured. `app.graph.schema.values/coerce` shapes the value first — turning a + matrix record into six doubles, a hex colour into a packed integer — so this + function only has to escape plain data. + + `map-key-fn` renders the keys of a `MAP(STRING, …)`; the caller supplies it + because the right form is a property of the column, not of this function + (`app.graph.schema.contract/map-key-fn`)." + ([ladybug-type v] (format-typed-value ladybug-type v nil)) + ([ladybug-type v map-key-fn] + (let [v (values/coerce ladybug-type v)] + (cond + (nil? v) + "NULL" + + (list-type? ladybug-type) + (format-typed-list ladybug-type v) + + (map-type? ladybug-type) + (let [[key-type value-type] (values/map-types ladybug-type) + entries (seq v) + format-key (if (and map-key-fn (= "STRING" key-type)) + #(format-string (map-key-fn (key %))) + #(format-typed-value key-type (key %)))] + (str "map([" (str/join ", " (map format-key entries)) + "], [" + (str/join ", " (map #(format-typed-value value-type (val %)) entries)) + "])")) + + (struct-type? ladybug-type) + (format-struct ladybug-type v) + + (= ladybug-type "JSON") + (format-json v) + + ;; Coerce string ids from transit edge-cases into UUID literals. + (= ladybug-type "UUID") + (format-uuid v) + + (= ladybug-type "TIMESTAMP") + (format-timestamp v) + + :else + (format-value v))))) + +(defn- ensure-semicolon + [statement] + (let [s (str/trim (str statement))] + (if (str/ends-with? s ";") s (str s ";")))) + +(defn- value->clj + [^Value value] + (when-not (.isNull value) + (let [v (try + (.getValue value) + (catch Exception _ + ;; LIST/STRUCT values are not supported by the binding's + ;; getValue (\"value_get_value\"); fall back to the textual + ;; representation so console queries do not crash. + (.toString value)))] + (cond + (instance? Long v) v + (instance? Integer v) (long v) + (instance? Double v) v + :else v)))) + +(defn- check-success! + [^QueryResult result statement] + (when-not (.isSuccess result) + (let [err (.getErrorMessage result)] + (ex/raise :type :internal + :code :ladybug-query-failed + :hint (str "Ladybug query failed: " err) + :statement statement + :err err)))) + +(defn- query-columns + [^QueryResult result] + (let [ncols (.getNumColumns result)] + (vec (for [i (range ncols)] + (.getColumnName result (long i)))))) + +(defn- query-row + [^FlatTuple tuple ncols] + (vec (for [i (range ncols)] + (with-open [^Value value (.getValue tuple (long i))] + (value->clj value))))) + +(def ^:private default-query-max-rows 200) + +(defn- read-query-rows + [^QueryResult result ncols max-rows] + (loop [rows [] n 0] + (if (and (< n max-rows) (.hasNext result)) + (let [row (with-open [^FlatTuple tuple (.getNext result)] + (query-row tuple ncols))] + (recur (conj rows row) (inc n))) + rows))) + +(defn query-on-connection! + "Execute a Cypher query on `conn` and return tabular results. + + Returns `{:columns [...] :rows [[...] ...] :truncated? bool}`." + [^Connection conn statement & {:keys [max-rows] + :or {max-rows default-query-max-rows}}] + (let [cypher (ensure-semicolon statement)] + (with-open [^QueryResult result (.query conn cypher)] + (check-success! result cypher) + (let [ncols (long (.getNumColumns result)) + columns (query-columns result) + rows (read-query-rows result ncols max-rows) + total (long (.getNumTuples result))] + {:columns columns + :rows rows + :truncated? (and (pos? total) (> total (count rows)))})))) + +(def ^:private default-query-timeout-ms + "0 disables query timeout (recommended for bulk COPY ingest)." + 0) + +(defn- scalar-value + [^Connection conn statement] + (let [cypher (ensure-semicolon statement)] + (with-open [^QueryResult result (.query conn cypher)] + (check-success! result cypher) + (when (.hasNext result) + (with-open [^FlatTuple tuple (.getNext result)] + (with-open [^Value value (.getValue tuple 0)] + (value->clj value))))))) + +(defn- extension-statement-ok? + [err-msg] + (let [err (str/lower-case (or err-msg ""))] + (or (str/includes? err "already loaded") + (str/includes? err "already installed")))) + +(defn- run-extension-statement! + [^Connection conn statement] + (let [cypher (ensure-semicolon statement)] + (with-open [^QueryResult result (.query conn cypher)] + (when-not (.isSuccess result) + (let [err (.getErrorMessage result)] + (when-not (extension-statement-ok? err) + (check-success! result cypher))))))) + +(defn ensure-extensions! + "Install and load Ladybug extensions required by graph ingest and sync." + [^Connection conn] + (run-extension-statement! conn "INSTALL json;") + (run-extension-statement! conn "LOAD json;")) + +(defn- run-statements! + [^Connection conn statements] + (doseq [statement statements] + (let [cypher (ensure-semicolon statement)] + (with-open [^QueryResult result (.query conn cypher)] + (check-success! result cypher))))) + +(defn- ensure-db-path! + [db-path] + (when-not (memory-db-path? db-path) + (fs/create-dir (fs/parent db-path)))) + +(defn with-connection! + "Open a Ladybug connection for `db-path` and invoke `(f conn)`. + + Options: + - `:query-timeout-ms` query timeout in milliseconds (default 0, disabled) + + For `:memory:`, the database only lives for the duration of this call; + all reads and writes must happen inside `f`." + [db-path f & {:keys [query-timeout-ms] + :or {query-timeout-ms default-query-timeout-ms}}] + (ensure-db-path! db-path) + (let [^Database db (if (memory-db-path? db-path) + (Database.) + (Database. (str db-path)))] + (try + (let [^Connection conn (Connection. db)] + (try + (.setQueryTimeout conn (long query-timeout-ms)) + (ensure-extensions! conn) + (f conn) + (finally + (.close conn)))) + (finally + (.close db))))) + +(defn exec-on-connection! + "Execute Cypher statements on an open Ladybug connection." + [^Connection conn statements] + (assert (sequential? statements) "statements should be a sequential collection") + (run-statements! conn statements)) + +;; --- prepared statements + +(defn- ->param-value + "Clojure scalar → `Value` for prepared-statement binding. + + This is the only `Value` constructor on the write path, so every parameter + is wrapped here. Parameters are scalars: the `Value` constructor takes no + list or map, so `MAP`, `STRUCT` and `T[]` columns stay literal-rendered + (`format-typed-value`) and the `:else` raise below means a caller tried to + bind one." + ^Value [v] + (cond + (nil? v) (Value/createNull) ; no explicit type needed + (uuid? v) (Value. ^Object v) ; native UUID + (string? v) (Value. ^Object v) + (boolean? v) (Value. ^Object v) + (integer? v) (Value. ^Object (long v)) + (number? v) (Value. ^Object (double v)) + (keyword? v) (Value. ^Object (name v)) + + (instance? java.time.Instant v) ; native TIMESTAMP + (Value. ^Object v) + + (instance? java.util.Date v) + (Value. ^Object (.toInstant ^java.util.Date v)) + + :else + (ex/raise :type :internal + :code :ladybug-unsupported-param + :hint (str "cannot bind a " (type v) " as a Ladybug parameter; " + "compound columns must be literal-rendered") + :value v))) + +(defn- as-statement + "Normalize a statement to `{:cypher … :params …}`. + + A bare string binds nothing, so the sync builders can convert to bound + parameters one family at a time." + [stmt] + (if (map? stmt) + (update stmt :params #(or % {})) + {:cypher stmt :params {}})) + +(defn prepare-on-connection! + "Parse and bind `statement` on `conn` without executing it. + + The returned `PreparedStatement` is a JNI resource: the caller closes it." + ^PreparedStatement [^Connection conn statement] + (let [cypher (ensure-semicolon statement) + ps (.prepare conn cypher)] + (when-not (.isSuccess ps) + (let [err (.getErrorMessage ps)] + (.close ps) + (ex/raise :type :internal + :code :ladybug-prepare-failed + :hint (str "Ladybug prepare failed: " err) + :statement cypher + :err err))) + ps)) + +(defn execute-prepared! + "Bind `params` into `ps` and execute it on `conn`. + + `params` keys are parameter names without the `$` (keyword or string); + values are scalars. Every bound `Value` is closed, including the ones built + before a later parameter is rejected." + [^Connection conn ^PreparedStatement ps params] + (let [vmap (java.util.HashMap.)] + (try + (doseq [[k v] params] + (.put vmap (name k) (->param-value v))) + (with-open [^QueryResult result (.execute conn ps vmap)] + (check-success! result "<prepared>")) + (finally + (run! #(.close ^Value %) (.values vmap)))))) + +(defn exec-prepared-on-connection! + "Prepare all statements, then execute all of them. + + A parse or bind failure in *any* statement aborts the batch before the first + mutation runs — the bind-level batch gate. Statements are + `{:cypher … :params {…}}` maps or bare strings." + [^Connection conn stmts] + (assert (sequential? stmts) "statements should be a sequential collection") + (let [prepared (volatile! [])] + (try + (doseq [stmt stmts] + (let [{:keys [cypher params]} (as-statement stmt)] + (vswap! prepared conj {:ps (prepare-on-connection! conn cypher) + :params params}))) + (doseq [{:keys [ps params]} @prepared] + (execute-prepared! conn ps params)) + (finally + (run! #(.close ^PreparedStatement (:ps %)) @prepared))))) + +(defn validate-on-connection! + "Binder gate: parse and semantic-check `statement` against the live schema, + without executing it. + + Returns `{:ok? … :error … :read-only? …}`. Unlike `prepare-on-connection!` + a failure is a return value rather than a raise: the callers are gates (the + CI binder gate, the console read-only gate) that report it. `:read-only?` is + the engine's own read/write analysis." + [^Connection conn statement] + (with-open [^PreparedStatement ps (.prepare conn (ensure-semicolon statement))] + (let [ok? (.isSuccess ps)] + {:ok? ok? + :error (when-not ok? (.getErrorMessage ps)) + :read-only? (when ok? (.isReadOnly ps))}))) + +(defn query-scalar-on-connection! + "Execute a query expected to return a single scalar value on `conn`." + [^Connection conn statement] + (scalar-value conn statement)) + +(defn exec! + "Execute Cypher statements against a Ladybug database. + + `db-path` is either `:memory:` or a filesystem path to a `.lbug` database." + [db-path statements] + (with-connection! db-path + (fn [conn] + (exec-on-connection! conn statements)))) + +(defn query-scalar! + "Execute a query expected to return a single scalar value." + [db-path statement] + (with-connection! db-path + (fn [conn] + (query-scalar-on-connection! conn statement)))) + +(defn smoke-test! + "Run a minimal CREATE + count against Ladybug." + [& {:keys [db-path] :or {db-path ":memory:"}}] + (when-not (memory-db-path? db-path) + (reset-db-path! db-path)) + (with-connection! db-path + (fn [^Connection conn] + (run-statements! conn + ["CREATE NODE TABLE Person(name STRING, age INT64, PRIMARY KEY(name));" + "CREATE (:Person {name: 'Alice', age: 25});" + "CREATE (:Person {name: 'Bob', age: 30});"]) + {:db-path db-path + :person-count (scalar-value conn + "MATCH (a:Person) RETURN count(a) AS c;")}))) diff --git a/backend/src/app/graph/meta.clj b/backend/src/app/graph/meta.clj new file mode 100644 index 0000000000..129babd1fa --- /dev/null +++ b/backend/src/app/graph/meta.clj @@ -0,0 +1,59 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.meta + "`GraphMeta`: the graph's own account of who built it and from what. + + A projected graph is a cache of a file at a revision, built by a known + schema. The row records both, so a reader can decide whether to reuse the + database or rebuild it: a `schema_version` that no longer matches the + registry, or a `source_revn` behind the file's, means the cache is stale. + + The row is written *last* in a build, so its presence also marks the build + complete. + + Keyed by `source_file_id` rather than holding a single row: a closure graph + is a union of per-file builds, and each contributing file keeps its own + provenance." + (:require + [app.common.time :as ct] + [app.graph.ladybug :as ladybug] + [app.graph.schema.nodes :as nodes]) + (:import + com.ladybugdb.Connection)) + +(set! *warn-on-reflection* true) + +(def table + "GraphMeta") + +(def producer + "penpot") + +(def ddl + "DDL for the provenance table." + (str "CREATE NODE TABLE `" table "` (" + "`source_file_id` UUID, " + "`producer` STRING, " + "`producer_version` STRING, " + "`schema_version` STRING, " + "`source_revn` INT64, " + "`built_at` TIMESTAMP, " + "PRIMARY KEY (`source_file_id`));")) + +(defn write! + "Record what this build produced for `file-id`." + [^Connection conn {:keys [file-id revn]}] + (ladybug/exec-on-connection! conn [ddl]) + (ladybug/exec-on-connection! + conn + [(str "MERGE (m:`" table "` {source_file_id: " (ladybug/format-uuid file-id) "}) " + "SET m.producer = " (ladybug/format-string producer) ", " + "m.producer_version = " (ladybug/format-string (or (System/getenv "PENPOT_BUILD") "devenv")) ", " + "m.schema_version = " (ladybug/format-string nodes/schema-version) ", " + "m.source_revn = " (ladybug/format-int (or revn 0)) ", " + "m.built_at = " (ladybug/format-timestamp (ct/now)) ";")])) + diff --git a/backend/src/app/graph/projection/document.clj b/backend/src/app/graph/projection/document.clj new file mode 100644 index 0000000000..9d0eec6851 --- /dev/null +++ b/backend/src/app/graph/projection/document.clj @@ -0,0 +1,214 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.projection.document + "Project a Penpot file-data map into Ladybug nodes and structural edges. + + Projects Document, Page, Component, the full shape tree (skipping the root + frame), and `IsChildOf` edges from shapes/pages/components to their parent. + + Two denormalizations happen here rather than in a later pass, because the + walk already has both answers in hand and a post-ingest statement would have + to rediscover them: + + - `page-id` on every shape, from the page the walk is currently in; + - `component-id` propagated from an instance head down to its descendants, + from the head context the walk carries." + (:require + [app.common.logging :as l] + [app.common.uuid :as uuid] + [app.graph.schema.nodes :as nodes])) + +(def root-frame-id + uuid/zero) + +(defn- document-attrs + "The Document node's attrs: the file row, minus its data blob. + + `:options` is lifted out of the blob before it goes: it is file-level + configuration a consumer wants without opening `:data`." + [file data] + (-> file + (assoc :id (or (:id data) (:id file))) + (cond-> (:options data) (assoc :options (:options data))) + (dissoc :data))) + +(defn- page-attrs + [page index] + (-> page + (dissoc :objects) + (cond-> (some? index) (assoc :index (long index))))) + +(defn- component-attrs + [component] + (-> component + (dissoc :objects) + ;; schema:component requires :path; some legacy rows omit it + (update :path #(or % "")))) + +(defn- shape-table + [shape] + (nodes/table-for-type (:type shape))) + +(defn denormalized-shape + "`shape` with `page-id` set and an inherited `component-id` filled in. + + A shape that carries its own `component-id` keeps it; `component-ctx` only + fills the gap for descendants (see `descend-component-ctx`)." + [shape page-id component-ctx] + (cond-> (assoc shape :page-id page-id) + (and (uuid? component-ctx) (nil? (:component-id shape))) + (assoc :component-id component-ctx))) + +(defn- shape-node-attrs + [table shape page-id component-ctx] + (nodes/project-attrs table (denormalized-shape shape page-id component-ctx))) + +(defn descend-component-ctx + "The component context to pass to `shape`'s children. + + Inheritance stops at the nearest ancestor Frame carrying a `component-id`, + and any intermediate shape that carries one is a barrier: + + - a Frame with its own `component-id` becomes the new context (it is an + instance head, and its descendants belong to *it*, not to an outer head); + - any other shape carrying a `component-id` blocks inheritance below it + without being able to supply one, since only Frames are heads; + - otherwise the context passes through unchanged." + [table shape ctx] + (let [own (:component-id shape)] + (cond + (and (some? own) (= table "Frame")) own + (some? own) ::blocked + :else ctx))) + +(defn- container-table? + [table] + (contains? nodes/container-tables table)) + +(defn- child-shape-ids + "Child ids in Penpot z-order (reversed from the stored :shapes list)." + [parent] + (when-let [shapes (:shapes parent)] + (vec (reverse shapes)))) + +(defn- initial-acc + [] + {:nodes {} + :edges [] + :stats {:documents 0 :pages 0 :components 0 :shapes 0}}) + +(declare project-shape-ids) + +(defn- project-shape + [objects acc table shape parent-table parent-id position page-id component-ctx] + (let [shape-id (:id shape) + acc' (-> acc + (update-in [:nodes table] (fnil conj []) + (shape-node-attrs table shape page-id component-ctx)) + (update :edges conj {:from-table table + :from-id shape-id + :to-table parent-table + :to-id parent-id + :position position}) + (update-in [:stats :shapes] inc))] + (if-let [child-ids (when (container-table? table) + (child-shape-ids shape))] + (project-shape-ids objects acc' table shape-id child-ids page-id + (descend-component-ctx table shape component-ctx)) + acc'))) + +(defn- project-shape-ids + [objects acc parent-table parent-id child-ids page-id component-ctx] + (reduce + (fn [acc [position shape-id]] + (if-let [shape (get objects shape-id)] + (if-let [table (shape-table shape)] + (project-shape objects acc table shape parent-table parent-id position + page-id component-ctx) + (do + (l/wrn :hint "unsupported shape type for graph slice" + :shape-id (str shape-id) + :type (:type shape)) + acc)) + (do + (l/wrn :hint "missing shape in page objects" + :shape-id (str shape-id)) + acc))) + acc + (map-indexed vector child-ids))) + +(defn- project-page + [acc doc-id page position] + (let [page-id (:id page) + objects (:objects page) + root (get objects root-frame-id) + page-node (nodes/project-attrs "Page" (page-attrs page position)) + acc' (-> acc + (update-in [:nodes "Page"] (fnil conj []) page-node) + (update :edges conj {:from-table "Page" + :from-id page-id + :to-table "Document" + :to-id doc-id + :position position}) + (update-in [:stats :pages] inc))] + (if-let [top-level-ids (child-shape-ids root)] + (project-shape-ids objects acc' "Page" page-id top-level-ids page-id nil) + acc'))) + +(defn- project-component + [acc doc-id component position] + (if (:deleted component) + acc + (let [comp-id (:id component) + node (nodes/project-attrs "Component" (component-attrs component))] + (-> acc + (update-in [:nodes "Component"] (fnil conj []) node) + (update :edges conj {:from-table "Component" + :from-id comp-id + :to-table "Document" + :to-id doc-id + :position position}) + (update-in [:stats :components] inc))))) + +(defn- project-components + [acc doc-id components] + (reduce (fn [acc [position [_id component]]] + (project-component acc doc-id component position)) + acc + (map-indexed vector components))) + +(defn projection-data + "Build node/edge rows for projecting `data` into Ladybug. + + Returns `{:nodes {table [attrs ...]} :edges [...] :stats {...}}`." + [data file] + (let [doc-id (or (:id data) (:id file)) + doc-node (nodes/project-attrs "Document" (document-attrs file data)) + ;; `:pages` is the tab order the user sees, and `Page.index` and the + ;; page's `IsChildOf.position` are that order. Child shapes are + ;; reversed on the way in (`child-shape-ids`) because their stored + ;; list runs bottom to top; pages have no such second ordering. + pages (seq (:pages data)) + comps (seq (:components data)) + acc0 (-> (initial-acc) + (update-in [:nodes "Document"] (fnil conj []) doc-node) + (assoc-in [:stats :documents] 1)) + acc (cond-> acc0 + (seq comps) + (project-components doc-id comps)) + acc (if (empty? pages) + acc + (reduce (fn [acc [position page-id]] + (if-let [page (get-in data [:pages-index page-id])] + (project-page acc doc-id page position) + (do + (l/wrn :hint "missing page in pages-index" + :page-id (str page-id)) + acc))) + acc + (map-indexed vector pages)))] + (select-keys acc [:nodes :edges :stats]))) diff --git a/backend/src/app/graph/projection/transforms.clj b/backend/src/app/graph/projection/transforms.clj new file mode 100644 index 0000000000..dc87392983 --- /dev/null +++ b/backend/src/app/graph/projection/transforms.clj @@ -0,0 +1,149 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.projection.transforms + "Derived graph links: edges a reader could compute from the projected + columns, materialized once at build time so a query does not have to. + + Each entry in `registry` names the transform, the relationship it produces, + and the function that produces it, so adding one is a single entry and + nothing else has to be told about it." + (:require + [app.common.logging :as l] + [app.graph.ladybug :as ladybug] + [app.graph.schema.nodes :as nodes]) + (:import + com.ladybugdb.Connection)) + +(set! *warn-on-reflection* true) + +(defn- run-scalar! + [^Connection conn statement] + (or (ladybug/query-scalar-on-connection! conn statement) 0)) + +(defn- link-component-instances! + "`IsInstanceOf` from Frame instance heads to their Component. + + Every head is linked, the main instance and any copy root alike. + + `component-file` is what makes a head a head here, not `component-id` alone. + `app.common.types.component/instance-of?` requires both, and the projection + denormalizes `component-id` down the shape tree + (`app.graph.projection.document`), so on its own it no longer distinguishes a + head from a shape that merely lives inside one. `component-file` is not + denormalized and remains the head marker Penpot itself uses." + [^Connection conn] + (run-scalar! conn + (str "MATCH (f:Frame), (c:Component) " + "WHERE f.component_id = c.id " + "AND f.component_file IS NOT NULL " + "AND NOT COALESCE(c.deleted, false) " + "MERGE (f)-[:IsInstanceOf]->(c) " + "RETURN count(*);"))) + +(defn- shape-pair-statements + "One statement per (from, to) shape-table pair. + + Ladybug cannot create a relationship bound by multiple node labels in a + single `MERGE`, a constraint inherited from Kùzu, which it forks (upstream + issue kuzudb/kuzu#5841). The loop over label pairs is that dialect + constraint, not a modelling choice." + [f] + (for [from nodes/shape-tables + to nodes/shape-tables] + (f from to))) + +(defn- link-shape-refs! + "`RefersTo` from an instance shape to its homologue in the main instance, + driven by `shape-ref`." + [^Connection conn] + (reduce + (fn [total statement] (+ total (run-scalar! conn statement))) + 0 + (shape-pair-statements + (fn [from to] + (str "MATCH (s:" (nodes/match-label from) "), (t:" (nodes/match-label to) ") " + "WHERE s.shape_ref = t.id " + "MERGE (s)-[:RefersTo]->(t) " + "RETURN count(*);"))))) + +(def ^:private swap-slot-prefix "swap-slot-") + +(def ^:private slot-uuid-expr + ;; Ladybug `substring` is 1-indexed; 36 = RFC 4122 UUID text length. + (str "substring(touched_key, " (inc (count swap-slot-prefix)) ", 36)")) + +(defn- link-swap-slots! + "`FillsSwapSlot` from a swapped-in shape to the slot it replaces. + + Penpot records a component sub-shape swap as a `swap-slot-<uuid>` entry in + the *replacing* shape's `touched` set, where `<uuid>` names the replaced + slot shape in the main instance. The entries are then stripped from + `touched`, as `app.common.types.component/normal-touched-groups` does, so a + reader of `touched` sees design edits rather than swap bookkeeping. + + Stripping makes this the one transform that writes a column another + transform could read. Anything reading `touched` has to run before it." + [^Connection conn] + (let [linked + (reduce + (fn [total statement] (+ total (run-scalar! conn statement))) + 0 + (shape-pair-statements + (fn [from to] + (str "MATCH (s:" (nodes/match-label from) ") " + "WHERE size(s.touched) > 0 " + "UNWIND s.touched AS touched_key " + "WITH s, touched_key " + "WHERE STARTS_WITH(touched_key, '" swap-slot-prefix "') " + "WITH s, CAST(" slot-uuid-expr ", 'UUID') AS slot_id " + "MATCH (t:" (nodes/match-label to) ") " + "WHERE t.id = slot_id AND s.id <> t.id " + "MERGE (s)-[r:FillsSwapSlot {slot_id: slot_id}]->(t) " + "RETURN count(r);"))))] + ;; Strip unconditionally: an entry may name a slot that was garbage + ;; collected, so "no edge created" does not mean "nothing to strip". + (doseq [table nodes/shape-tables] + (ladybug/exec-on-connection! + conn + [(str "MATCH (s:" (nodes/match-label table) ") " + "WHERE size(s.touched) > 0 " + "SET s.touched = list_filter(s.touched, x -> " + "NOT STARTS_WITH(x, '" swap-slot-prefix "'));")])) + linked)) + +(def registry + "Every transform this backend applies. + + `:id` names the transform in the ingest report and the log. `:rel` names + the relationship it produces. The three registered here read disjoint + columns, so the vector order is not load-bearing. The one ordering + constraint that exists is stated on `link-swap-slots!`." + [{:id "link-component-instances" :rel :IsInstanceOf :run link-component-instances!} + {:id "link-shape-refs" :rel :RefersTo :run link-shape-refs!} + {:id "link-swap-slots" :rel :FillsSwapSlot :run link-swap-slots!}]) + +(defn apply-transforms! + "Apply every registered transform to an already loaded graph. + + Returns `{:ids [...] :counts {...} :transforms n}`, where `:ids` names what + ran and `:counts` gives the edges each one produced." + [_system ^Connection conn _data _file] + (reduce + (fn [acc {:keys [id rel run]}] + (let [n (run conn)] + (l/inf :hint "graph transform" :transform id :edges n) + (-> acc + (update :ids conj id) + (update :counts assoc rel n) + (assoc rel n)))) + {:ids [] :counts {} :transforms (count registry)} + registry)) + +(defn transform-ids + "Ids of every transform in the registry." + [] + (mapv :id registry)) diff --git a/backend/src/app/graph/report.clj b/backend/src/app/graph/report.clj new file mode 100644 index 0000000000..f026ff3a52 --- /dev/null +++ b/backend/src/app/graph/report.clj @@ -0,0 +1,65 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.report + (:require + [clojure.core :as c] + [clojure.string :as str])) + +(defn- println! + [& lines] + (doseq [line lines] + (println line))) + +(defn- section-title + [title] + (println! (str "\n" title) + (str (apply str (repeat (count title) "─"))))) + +(defn- kv-line + [k v] + (format " %-14s %s" (str k ":") v)) + +(defn- print-node-counts + [nodes] + (doseq [[table count] (sort-by first nodes) + :when (pos? (long count))] + (println! (kv-line table count)))) + +(defn print-ingest! + "Pretty-print the result map returned by `app.graph.ingest/ingest-file!`." + [{:keys [file-id revn name db-path schema-version projection transforms stats]}] + (section-title "Graph ingest") + (println! (kv-line "File" (str name " (" file-id ")")) + (kv-line "Revision" revn) + (kv-line "Schema" schema-version) + (kv-line "Database" db-path)) + + (when-let [pstats (:stats projection)] + (section-title "Projection") + (doseq [[k v] (sort-by key pstats)] + (println! (kv-line (c/name k) v)))) + + (section-title "Transforms") + (println! (kv-line "Applied" (or (:transforms transforms) 0))) + (doseq [[rel count] (sort-by key (:counts transforms))] + (println! (kv-line (c/name rel) count))) + (when-let [ids (seq (:ids transforms))] + (println! (kv-line "Recorded" (str/join ", " ids)))) + + (when stats + (section-title "Graph counts") + (when-let [nodes (:nodes stats)] + (println! " Nodes") + (print-node-counts nodes)) + (when-let [edges (:edges stats)] + (println! " Edges") + (doseq [[rel count] (sort-by key edges) + :when (pos? (long count))] + (println! (kv-line (c/name rel) count))))) + + (println!) + nil) diff --git a/backend/src/app/graph/schema.clj b/backend/src/app/graph/schema.clj new file mode 100644 index 0000000000..0aace14bea --- /dev/null +++ b/backend/src/app/graph/schema.clj @@ -0,0 +1,30 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.schema + "Ladybug DDL facade for the graph-backed Penpot vertical slice. + + Node metadata and DDL generation live in `app.graph.schema.nodes`." + (:require + [app.graph.schema.nodes :as nodes])) + +(def schema-version + nodes/schema-version) + +(def container-node-tables + nodes/container-tables) + +(def shape-node-tables + nodes/shape-tables) + +(def node-tables + (mapv (fn [{:keys [table schema]}] + {:name table :schema schema}) + nodes/node-types)) + +(defn ddl-statements + [] + (nodes/ddl-statements)) diff --git a/backend/src/app/graph/schema/contract.clj b/backend/src/app/graph/schema/contract.clj new file mode 100644 index 0000000000..684a12dd2e --- /dev/null +++ b/backend/src/app/graph/schema/contract.clj @@ -0,0 +1,150 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.schema.contract + "Deliberate choices in Penpot's graph schema, recorded as data. + + Penpot must pick a spelling and a type for every graph column. A Ladybug + column gets both once, at table creation, and neither widens afterwards. The + choices are therefore worth making deliberately and worth recording. + + Three of them live here: + + - `column-name` maps a Penpot key to its column. The rule is snake_case of + the key, and `renames` records every exception. + - `dropped-keys` and `per-table-dropped` name Penpot keys that deliberately + get no column. + - `type-overrides` pins the Ladybug type where the Malli-derived one + (`app.graph.schema.types`) is coarser than the column deserves. + + Each entry carries its reason. A divergence from the default rule is then a + diff to review rather than a silent rename." + (:require + [app.common.json :as json] + [clojure.string :as str])) + +(def ^:private renames + "Penpot key to column name, where the column is not snake_case of the key. + + Keyed by the Penpot key alone: no shape type gives one of these a second + meaning, so a per-table map would only add ceremony." + {;; `bool` collides with the Ladybug type name, so the column is named after + ;; the table (`Boolean`) rather than after Penpot's `:bool` shape type. + :bool-type "boolean_type" + + ;; The column records what the file saved, which can lag what the shape + ;; tree implies. The `saved_` prefix marks it as the stored value rather + ;; than a derivation. + :component-root "saved_component_root" + + ;; The value is a list, so the plural is accurate. + :shadow "shadows" + + ;; The column spells the revision number out. + :revn "revision"}) + +(def dropped-keys + "Penpot keys projected by the Malli registry that get no column. + + Dropping is right only when the column would be dead weight for every reader + of the graph. A key a reader might learn from belongs in `unprojected-keys` + instead." + {:deleted-at + "Only non-nil for a soft-deleted file, and a deleted file is never ingested." + + :pixel-grid-color + "Viewer chrome: the color of the editor's pixel grid, not design content." + + :pixel-grid-opacity + "Viewer chrome, as above."}) + +(def unprojected-keys + "Penpot keys that should become graph columns and do not have one yet. + + Distinct from `dropped-keys` on purpose: these are a debt the projection + owes, not a decision to discard data. Keeping the two apart means a new + upstream attribute cannot be quietly buried in the drop list." + {:background-blur + "Landed upstream behind a default-on flag. No column for it yet."}) + +(def ^:private per-table-dropped + "Keys dropped only on certain tables. + + `:grids` is the standing case: Penpot's shape schema admits it on every + shape, but only a Frame ever carries one. Emitting an always-null column on + ten other tables would widen every multi-table scan for nothing." + {:grids #{"Boolean" "Circle" "Group" "Image" "Path" "Rectangle" "SVGRaw" "Text"}}) + +(def type-overrides + "Ladybug column type per column name, where the derived type is too coarse. + + `app.graph.schema.types` derives a type from the Malli schema, which is the + right default but coarser than the column deserves in places: a Malli `:map` + becomes `JSON`, where a native Ladybug MAP or a fixed-size array lets a + consumer read a tensor row without parsing. + + Only load-bearing divergences are pinned here, in the order they became + load-bearing." + {;; Must be a native MAP: a JSON blob cannot be indexed by key in Cypher, so + ;; `map_keys` and `map_extract` cannot reach a single token at all. + "applied_tokens" "MAP(STRING, STRING)" + + ;; `grc/schema:rect` is an inline `:and` over a map, not the registered + ;; `::grc/rect`, so `app.graph.schema.types` cannot recognize it by type. + ;; Four doubles rather than the eight-field struct: `x1`/`y1`/`x2`/`y2` are + ;; derivable from `x`/`y`/`width`/`height`, and a fixed-size array is a + ;; tensor row a consumer reads without parsing. + "selrect" "DOUBLE[4]" + + ;; The SVG provenance attributes are typed `:map` in the shape schema on + ;; purpose. Legacy files hold them as plain maps rather than as + ;; `::grc/rect` and `::gmt/matrix` records, and a tighter *schema* would + ;; reject those files + ;; (`app.common.types.shape/schema:shape-generic-attrs`). A tighter + ;; *column* is free: `app.graph.schema.values/coerce` reads either form. + "svg_viewbox" "DOUBLE[4]" + "svg_transform" "DOUBLE[6]" + + ;; `:fills` is an `:or` over the packed `app.common.types.fills` value and + ;; a plain vector of fill maps, so the schema alone cannot say it is a + ;; collection. It always is one, and a fill has enough optional shape + ;; (solid, gradient, image) that JSON per element is the honest element + ;; type. + "fills" "JSON[]"}) + +(def ^:private map-key-fns + "How to render the *keys* of a MAP column, per column. + + A column name is schema, so it is snake_case. The keys inside a MAP are + values, so they keep the spelling their producer used. `applied_tokens` is + keyed by shape attribute in the camelCase form + `app.common.json/write-camel-key` produces: `strokeWidth`, not + `stroke-width`." + {"applied_tokens" json/write-camel-key}) + +(defn map-key-fn + "Key renderer for a MAP column. `name` unless the column says otherwise." + [column] + (get map-key-fns column name)) + +(defn column-name + "The graph column name for Penpot key `k`. + + Default: snake_case of the key. `renames` overrides." + [k] + (or (get renames k) + (str/replace (name k) "-" "_"))) + +(defn drop-key? + "Should key `k` be omitted from `table`'s columns?" + [table k] + (or (contains? dropped-keys k) + (contains? (get per-table-dropped k #{}) table))) + +(defn ladybug-type + "The pinned Ladybug type for `column`, or `fallback` when nothing is pinned." + [column fallback] + (get type-overrides column fallback)) diff --git a/backend/src/app/graph/schema/nodes.clj b/backend/src/app/graph/schema/nodes.clj new file mode 100644 index 0000000000..c81802415d --- /dev/null +++ b/backend/src/app/graph/schema/nodes.clj @@ -0,0 +1,343 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.schema.nodes + "Single source of truth for graph node tables. + + Each registry entry declares Penpot Malli sources plus projection + options (`:drop`, optional `:extra`). Derived artifacts — Ladybug + DDL, Arrow fields, validation, type dispatch — all flow from that. + + This registry is the single source of the graph schema. A Ladybug column + gets its name and its type once, at table creation, and there is no + widening afterwards. Every divergence between a Penpot key and its column + is recorded in `app.graph.schema.contract`." + (:require + [app.common.exceptions :as ex] + [app.common.schema :as sm] + [app.common.time :as ct] + [app.common.types.component :as ctk] + [app.common.types.file :as ctf] + [app.common.types.page :as ctp] + [app.graph.ladybug :as ladybug] + [app.graph.schema.contract :as contract] + [app.graph.schema.projection :as projection] + [app.graph.schema.types :as types] + [clojure.string :as str])) + +(def schema-version + "penpot-graph-slice-4") + +(def ^:private document-projection + {:source ctf/schema:file + :drop [:data] + ;; Attributes a file map carries that `ctf/schema:file` does not declare. + ;; + ;; They belong here rather than in that schema, even though the graph wants + ;; them, because `schema:file` is on the *write* path too: + ;; `app.binfile.common/update-file!` derives its UPDATE columns from a file + ;; map's keys, so declaring `:backend` there made it try to write a `backend` + ;; column, which the `file` table does not have — it is synthesized on read. + ;; A projection `:extra` is local to the graph and cannot reach a write. + ;; + ;; `:options` is lifted out of `:data` before the blob is dropped + ;; (`app.graph.projection.document/document-attrs`); the rest come off the file + ;; map as `get-file` returns it. + :extra [:map + [:options {:optional true} [:maybe :map]] + [:backend {:optional true} [:maybe :string]] + [:comment-thread-seqn {:optional true} [:maybe :int]] + [:ignore-sync-until {:optional true} [:maybe ::ct/inst]]]}) + +(def ^:private page-projection + {:source ctp/schema:page + :drop [:objects]}) + +(def ^:private component-projection + {:source ctk/schema:component + :drop [:objects] + ;; Soft-delete flag used at runtime; not in schema:component. + :extra [:map + [:deleted {:optional true} :boolean] + [:annotation {:optional true} :string]]}) + +(def ^:private shape-projection + {:drop [:type]}) + +(def ^:private shape-node-types + [{:table "Frame" :penpot-type :frame :container? true} + {:table "Group" :penpot-type :group :container? true} + {:table "Boolean" :penpot-type :bool :container? true} + {:table "SVGRaw" :penpot-type :svg-raw :container? true} + {:table "Rectangle" :penpot-type :rect} + {:table "Circle" :penpot-type :circle} + {:table "Path" :penpot-type :path} + {:table "Text" :penpot-type :text} + {:table "Image" :penpot-type :image}]) + +(defn- resolve-schema + [{:keys [schema source drop extra penpot-type]}] + (or schema + (when penpot-type + (projection/project-shape-schema penpot-type + {:drop drop + :extra extra})) + (projection/project-schema source + {:drop drop + :extra extra}))) + +(defn- shape-node-entry + [{:keys [table penpot-type container?] :as entry}] + (let [projection (-> shape-projection + (merge (:projection entry)) + (assoc :penpot-type penpot-type))] + {:table table + :pk :id + :penpot-type penpot-type + :container? container? + :projection projection + :schema (resolve-schema projection)})) + +(def node-types + "Ordered node registry." + (into [{:table "Document" + :pk :id + :projection document-projection + :schema (resolve-schema document-projection)} + {:table "Page" + :pk :id + :projection page-projection + :schema (resolve-schema page-projection)} + {:table "Component" + :pk :id + :projection component-projection + :schema (resolve-schema component-projection)}] + (map shape-node-entry shape-node-types))) + +(def ^:private by-table + (into {} (map (juxt :table identity) node-types))) + +(def ^:private by-penpot-type + (into {} (keep (fn [{:keys [penpot-type table]}] + (when penpot-type [penpot-type table])) + node-types))) + +(def container-tables + (into #{} (comp (filter :container?) (map :table)) node-types)) + +(def shape-tables + (into [] (comp (filter :penpot-type) (map :table)) node-types)) + +(defn table-for-type + "Map a Penpot shape `:type` keyword to a Ladybug node table name." + [penpot-type] + (get by-penpot-type (keyword penpot-type))) + +(defn node-entry + [table] + (get by-table table)) + +(defn projection-for + "Return the projection options map for `table`." + [table] + (:projection (node-entry table))) + +(defn- entry-child-schema + "Return the value schema from a Malli map entry (`[k s]` or `[k props s]`)." + [entry] + (if (> (count entry) 2) + (nth entry 2) + (nth entry 1))) + +(defn column-name + "Graph column name for projected key `k` on `table`." + [_table k] + (contract/column-name k)) + +(defn column-ladybug-type + "Ladybug column type for projected key `k` on `table`." + [table k] + (some (fn [entry] + (when (= k (first entry)) + (contract/ladybug-type (column-name table k) + (types/ladybug-type (entry-child-schema entry))))) + (projection/schema-map-entries (:schema (node-entry table))))) + +(defn column-keys + "Projected column keys for `table`, in registry order. + + Keys the contract drops on this table are omitted, so the column order, the + Arrow batch, and the DDL cannot disagree about what exists." + [table] + (into [] + (comp (map first) + (remove #(contract/drop-key? table %))) + (projection/schema-map-entries (:schema (node-entry table))))) + +(defn columns + "Projected column names for `table`, in registry order." + [table] + (mapv #(column-name table %) (column-keys table))) + +(def ^:private validate-node-fn + (memoize + (fn [table] + (let [{:keys [schema]} (node-entry table)] + (sm/check-fn schema + :type :validation + :code (keyword "graph-node-projection" (str/lower-case table)) + :hint (str "invalid graph node projection for " table)))))) + +(defn- projection-error-hint + [table explain] + (str "invalid graph node projection for " table + (when explain + (str "\n" (sm/humanize-explain explain))))) + +(defn validate-node + "Validate and return projected node attrs for `table`." + [table value] + (let [{:keys [schema]} (node-entry table)] + (try + ((validate-node-fn table) value) + (catch clojure.lang.ExceptionInfo e + (let [data (ex-data e) + explain (or (::sm/explain data) + (sm/explain schema value))] + (ex/raise :type :validation + :code (keyword "graph-node-projection" (str/lower-case table)) + :hint (projection-error-hint table explain) + :table table + ::sm/explain explain + :cause e)))))) + +(defn- get-projected-attr + "The attribute under `k`, keyword or string key. + + `if-some`, not `or`: `false` and `0` are values, and falling through on them + is how `opacity 0` became `nil` and then the column default." + [attrs k] + (if-some [v (get attrs k)] + v + (when (keyword? k) (get attrs (name k))))) + +(defn- raise-empty-projection! + [table attrs] + (ex/raise :type :validation + :code (keyword "graph-node-projection" (str/lower-case table)) + :hint (str "empty graph node projection for " table + "; columns=" (count (column-keys table)) + " shape-keys=" (vec (keys attrs))))) + +(defn project-attrs + "Select and validate the projected columns for `table` from `attrs`." + [table attrs] + ;; `some?`, not truthiness: `false` and `0` are values. Dropping them sent + ;; `opacity 0` to the column default of 1.0 — a fully transparent shape + ;; projected as opaque. + (let [projected (into {} + (keep (fn [k] + (let [v (get-projected-attr attrs k)] + (when (some? v) [k v]))) + (column-keys table)))] + (when (empty? projected) + (raise-empty-projection! table attrs)) + (validate-node table projected))) + +(defn match-label + "Cypher node label for MATCH; backtick-wrapped when required by Ladybug." + [table] + (if (#{"Group" "Boolean"} table) + (str "`" table "`") + table)) + +(defn cypher-property-key + "Backtick-wrapped column name for inline Cypher literals." + [table k] + (str "`" (column-name table k) "`")) + +(defn column-map-key-fn + "How a MAP column of `table` renders its keys. + + A MAP's keys are values, not schema, so they keep the spelling their consumer + parsed — `applied_tokens` is keyed in camelCase. Both writers need this, so it + lives next to the column's type rather than in either of them." + [table k] + (contract/map-key-fn (column-name table k))) + +(defn format-column-value + "Cypher literal for `v` in column `k` of `table`. + + The single place that knows both the column's Ladybug type and the contract + detail that a MAP column may render its keys differently from `name` — used + by the bulk loader's post-COPY fixups and by the incremental sync alike, so + the two cannot disagree about a value's shape." + [table k v] + (ladybug/format-typed-value (column-ladybug-type table k) + v + (column-map-key-fn table k))) + +(defn- create-node-table-ddl + [{:keys [table pk]}] + (let [cols (for [k (column-keys table)] + (str "`" (column-name table k) "` " (column-ladybug-type table k)))] + (str "CREATE NODE TABLE `" table "` (" + (str/join ", " (concat cols + [(str "PRIMARY KEY (`" (column-name table pk) "`)")])) + ");"))) + +(defn is-child-of-ddl + [] + (str "CREATE REL TABLE `IsChildOf` (" + "FROM `Page` TO `Document`, " + "FROM `Component` TO `Document`, " + (str/join ", " + (concat + (map (fn [shape] + (str "FROM `" shape "` TO `Page`")) + shape-tables) + (for [shape shape-tables + container container-tables] + (str "FROM `" shape "` TO `" container "`")))) + ", `position` INT64);")) + +(defn is-instance-of-ddl + "Frame instance heads → Component." + [] + "CREATE REL TABLE `IsInstanceOf` (FROM `Frame` TO `Component`);") + +(defn- shape-to-shape-rel-ddl + "A rel table over the full shape × shape product. + + Created up-front rather than on demand: the bulk loader must never race on + lazy table creation, and a consumer can then tell \"this producer cannot + emit that pair\" from \"this document happens to have none\"." + [rel props] + (str "CREATE REL TABLE `" rel "` (" + (str/join ", " (for [from shape-tables + to shape-tables] + (str "FROM `" from "` TO `" to "`"))) + (when (seq props) (str ", " (str/join ", " props))) + ");")) + +(defn refers-to-ddl + "Instance shape → its homologue in the component main instance, resolved + from `shape-ref`." + [] + (shape-to-shape-rel-ddl "RefersTo" nil)) + +(defn fills-swap-slot-ddl + "Swapped-in shape → the slot shape it replaces." + [] + (shape-to-shape-rel-ddl "FillsSwapSlot" ["`slot_id` UUID"])) + +(defn ddl-statements + [] + (-> (mapv create-node-table-ddl node-types) + (conj (is-child-of-ddl)) + (conj (is-instance-of-ddl)) + (conj (refers-to-ddl)) + (conj (fills-swap-slot-ddl)))) \ No newline at end of file diff --git a/backend/src/app/graph/schema/projection.clj b/backend/src/app/graph/schema/projection.clj new file mode 100644 index 0000000000..4d0e969329 --- /dev/null +++ b/backend/src/app/graph/schema/projection.clj @@ -0,0 +1,85 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.schema.projection + "Derive Ladybug node column schemas from Penpot Malli sources. + + Start from the canonical schema and remove the keys that must not become + graph columns." + (:require + [app.common.exceptions :as ex] + [app.common.schema :as sm] + [app.common.types.shape :as cts] + [malli.core :as m])) + +(def ^:private malli-opts sm/default-options) + +(defn- coerce-schema + "Normalize Malli sources to a compiled schema, unwrapping `:val` nodes." + [schema] + (loop [s (cond + (sm/schema? schema) schema + :else (sm/schema schema))] + (if (= :malli.core/val (sm/type s)) + (recur (first (sm/children s))) + s))) + +(defn- unsupported-projection-schema! + [schema] + (ex/raise :type :internal + :code :unsupported-projection-schema + :hint (str "unsupported projection schema type: " + (sm/type (coerce-schema schema))))) + +(defn schema-map-entries + "Map entries for `schema`, flattening `:merge` composites." + [schema] + (let [s (coerce-schema schema)] + (or (seq (sm/entries s)) + (unsupported-projection-schema! schema)))) + +(defn- select-projected-keys + "Project `schema` to a flat map schema, optionally dropping keys." + [schema drop-keys] + (let [s (coerce-schema schema) + keys (if (seq drop-keys) + (remove (set drop-keys) (sm/keys s)) + (sm/keys s))] + (sm/select-keys s (vec keys)))) + +(defn shape-type-schema + "Return the compiled Penpot Malli branch for shape type `penpot-type`. + + `m/entries` on the shape `:multi` yields MapEntries whose values are + compiled branch schemas (wrapped in `:val`). `m/children` returns raw + entry forms and must not be used here." + [penpot-type] + (let [kw (keyword penpot-type) + multi (sm/schema cts/schema:shape-attrs)] + (or (some (fn [entry] + (when (= kw (key entry)) + (val entry))) + (m/entries multi malli-opts)) + (ex/raise :type :validation + :code :unknown-shape-type + :hint (str "unknown penpot shape type: " kw))))) + +(defn project-schema + "Build a graph node schema from canonical Malli `source`. + + Options: + - `:drop` - keys removed from the source + - `:extra` - optional extra `[:map ...]` merged on top" + [source {:keys [drop extra]}] + (let [projected (select-projected-keys source drop)] + (if extra + (sm/merge projected (coerce-schema extra)) + projected))) + +(defn project-shape-schema + "Project `:drop` from the Penpot schema for `penpot-type`." + [penpot-type opts] + (project-schema (shape-type-schema penpot-type) opts)) diff --git a/backend/src/app/graph/schema/types.clj b/backend/src/app/graph/schema/types.clj new file mode 100644 index 0000000000..a905d92dcc --- /dev/null +++ b/backend/src/app/graph/schema/types.clj @@ -0,0 +1,174 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.schema.types + "Map Malli schemas to Ladybug column types. + + Ladybug is schema-first and strongly typed: every property key gets its type + at table-creation time, and there is no widening later. That makes this + mapping the whole of the graph's typing, and it is worth being tight — a + column typed `DOUBLE[4]` is four numbers a consumer reads as a tensor row, + where the same value as `JSON` is text somebody has to parse and trust. So + JSON is the fallback of last resort, taken only where the Malli schema + genuinely admits shapes no single column can hold. + + Three groups, in the order the mapping tries them: + + 1. **Scalars** (`base-type->ladybug`) — the leaf Malli types. + 2. **Registered composites** (`custom-type->ladybug`) — Penpot's own value + types whose *layout* is fixed even though Malli only sees a map or a + string: a matrix is six doubles, a point two, a rect four, a hex colour + one packed integer. These are named explicitly because the tight encoding + is a modelling decision, not something derivable from the schema. + 3. **Structure** — collections become `T[]`, `:map-of` becomes `MAP(k, v)`, + and a closed map of scalars becomes a `STRUCT`. Anything that could be + more than one shape (a `:multi`, an `:or`, an optional-keyed map) becomes + `JSON`, because a Ladybug column cannot be two types. + + Every encoding here has a matching value formatter in `app.graph.ladybug`. + The two must move together: a column type with no case there falls back to + guessing the literal from the runtime value." + (:require + [app.common.logging :as l] + [app.common.schema :as sm] + [app.common.time :as ct] + [clojure.string :as str] + [malli.core :as m])) + +(def ^:private malli-opts sm/default-options) + +(def ^:private base-type->ladybug + {::sm/uuid "UUID" + ::sm/safe-number "DOUBLE" + ::sm/safe-double "DOUBLE" + ::sm/safe-int "INT64" + ::sm/number "DOUBLE" + ::sm/boolean "BOOLEAN" + ::sm/int "INT64" + ::ct/inst "TIMESTAMP" + :uuid "UUID" + :string "STRING" + :int "INT64" + :double "DOUBLE" + :float "DOUBLE" + :boolean "BOOLEAN" + :keyword "STRING" + :inst "TIMESTAMP"}) + +(def ^:private custom-type->ladybug + "Penpot value types with a fixed layout Malli does not express. + + Fixed-size arrays are the point of each: they are dense, they need no + parsing, and a consumer can read a whole column as a tensor. + + - `::gmt/matrix` — the affine transform, `[a b c d e f]`. + - `::gpt/point` — `[x y]`. + - `::grc/rect` — `[x y width height]`. `x1`/`y1`/`x2`/`y2` are dropped: they + are derivable from those four, and carrying them would double the column. + - `::clr/hex-color` — `#RRGGBB` packed as `0xRRGGBBAA`, so colours compare + and group without string handling." + {:app.common.geom.matrix/matrix "DOUBLE[6]" + :app.common.geom.point/point "DOUBLE[2]" + :app.common.geom.rect/rect "DOUBLE[4]" + :app.common.types.color/hex-color "UINT32"}) + +(def ^:private collection-types + #{:vector :sequential :set ::sm/vec ::sm/set ::sm/coll}) + +(def ^:private string-collection-types + "Registered collection schemas whose element type is not in `children`." + {::sm/set-of-strings "STRING[]" + ::sm/set-of-keywords "STRING[]" + ::sm/set-of-uuid "UUID[]" + ::sm/vec-of-uuid "UUID[]"}) + +(defn- normalize-schema + "Resolve refs, but stop at a schema this namespace maps explicitly. + + Order matters: `::grc/rect` derefs to an `:and` over a map, and following + that would lose the fixed-size-array encoding." + [schema] + (let [s (sm/schema schema)] + (if (and (m/-ref-schema? s) + (not (contains? custom-type->ladybug (m/type s))) + (not (contains? string-collection-types (m/type s)))) + (recur (m/deref s malli-opts)) + s))) + +(declare ladybug-type) + +(defn- entry-child + "The value schema of a Malli map entry (`[k s]` or `[k props s]`)." + [entry] + (if (> (count entry) 2) (nth entry 2) (nth entry 1))) + +(defn- entry-optional? + [entry] + (and (> (count entry) 2) + (:optional (nth entry 1)))) + +(defn- struct-type + "`STRUCT(...)` for a closed map of scalars, or nil when JSON is the honest answer. + + A struct is a fixed layout: every field present, every field a single type. + An optional key would make the column's shape depend on the row, and a nested + collection or map makes it recursive — Ladybug allows nesting, but a consumer + reading such a column gains nothing over JSON, so the line is drawn at + scalars." + [s] + (let [entries (m/entries s malli-opts)] + (when (and (seq entries) + (not-any? entry-optional? entries)) + (let [fields (for [entry entries + :let [t (ladybug-type (entry-child entry))]] + (when (and t + (not= "JSON" t) + (not (str/includes? t "("))) + ;; snake_case like a column name, and always + ;; backtick-quoted: a grid cell has a field called + ;; `column`, which is a Ladybug keyword, and an unquoted + ;; one fails to parse in the DDL *and* in every literal. + ;; The catalog reports them unquoted. + (str "`" (str/replace (name (key entry)) "-" "_") "` " t)))] + (when (every? some? fields) + (str "STRUCT(" (str/join ", " fields) ")")))))) + +(defn ladybug-type + "Return the Ladybug column type for a Malli child schema." + [schema] + (let [s (normalize-schema schema) + t (m/type s)] + (or (base-type->ladybug t) + (custom-type->ladybug t) + (string-collection-types t) + (when (contains? collection-types t) + (when-let [child (first (m/children s malli-opts))] + (str (ladybug-type child) "[]"))) + (case t + (:maybe :and) (ladybug-type (first (m/children s malli-opts))) + + ;; `::sm/one-of` is how Penpot spells a closed set of keywords — + ;; `:blend-mode`, `:grow-type`, every `:layout-*`. One keyword, one + ;; string. + (:enum ::sm/one-of) "STRING" + + :map-of + (let [[key-schema value-schema] (m/children s malli-opts)] + (str "MAP(" (ladybug-type key-schema) ", " + (ladybug-type value-schema) ")")) + + :map (or (struct-type s) "JSON") + + ;; A schema we do not recognize. If it has no children it is a leaf — + ;; one of Penpot's registered keyword or enum schemas, say — and a + ;; string holds it exactly. If it has children it is a composite whose + ;; shape we cannot pin down, and JSON is the honest answer. + (if (empty? (m/children s malli-opts)) + "STRING" + (do + (l/wrn :hint "unmapped composite malli type, defaulting to JSON" + :malli-type t) + "JSON")))))) diff --git a/backend/src/app/graph/schema/values.clj b/backend/src/app/graph/schema/values.clj new file mode 100644 index 0000000000..e93b74f499 --- /dev/null +++ b/backend/src/app/graph/schema/values.clj @@ -0,0 +1,202 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.schema.values + "Shape a Penpot value into the plain data its Ladybug column type wants. + + Ladybug is strongly typed, and `app.graph.schema.types` maps Penpot's Malli + schemas onto types as tight as it can — a matrix is `DOUBLE[6]`, a rect + `DOUBLE[4]`, a colour `UINT32`, a closed map a `STRUCT`. A tight column is + only worth having if the writer actually fills it in that shape, which is + what this namespace does: it turns records and maps into the numbers, vectors + and plain maps the type names. + + It deliberately stops there. Serialization belongs to the writer — Cypher + literals in `app.graph.ladybug`, Arrow vectors in `app.graph.arrow` — so that + shaping a value and writing it are separate concerns and each has one home. + + The type language is the Ladybug one, read recursively: `T[]`, `T[n]`, + `MAP(k, v)`, `STRUCT(name t, …)`. Anything else is passed through." + (:require + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] + [app.common.types.color :as clr] + [clojure.string :as str])) + +(defn- split-args + "Split a comma-separated type argument list, respecting nesting. + + `\"UUID, STRUCT(a INT64, b INT64)\"` → `[\"UUID\" \"STRUCT(a INT64, b INT64)\"]`." + [s] + (loop [chars (seq s) depth 0 current (StringBuilder.) out []] + (if-let [c (first chars)] + (cond + (and (= c \,) (zero? depth)) + (recur (rest chars) depth (StringBuilder.) (conj out (str/trim (str current)))) + + (or (= c \() (= c \[)) + (recur (rest chars) (inc depth) (.append current c) out) + + (or (= c \)) (= c \])) + (recur (rest chars) (dec depth) (.append current c) out) + + :else + (recur (rest chars) depth (.append current c) out)) + (let [last-arg (str/trim (str current))] + (cond-> out (seq last-arg) (conj last-arg)))))) + +(defn- parse-list + "`[element-type]` when `t` is a list or fixed-size array type, else nil. + + `DOUBLE[]` and `DOUBLE[4]` are both lists of doubles as far as shaping goes; + the size only matters to the DDL." + [t] + (when-let [[_ element] (re-matches #"(.+?)\[\d*\]$" t)] + [element])) + +(defn- parse-map + "`[key-type value-type]` when `t` is a MAP type, else nil." + [t] + (when-let [[_ args] (re-matches #"MAP\((.*)\)$" t)] + (let [[k v] (split-args args)] + (when (and k v) [k v])))) + +(defn- parse-struct + "`[[field-name field-type] …]` when `t` is a STRUCT type, else nil. + + Field names arrive backtick-quoted (see `app.graph.schema.types`). The + quoting is syntax, so it is stripped by default and re-applied by the writer — + except for the Arrow writer, which needs it kept (`keep-quotes?`)." + [t keep-quotes?] + (when-let [[_ args] (re-matches #"STRUCT\((.*)\)$" t)] + (for [arg (split-args args) + :let [idx (str/index-of arg " ")] + :when idx] + [(cond-> (subs arg 0 idx) (not keep-quotes?) (str/replace "`" "")) + (str/trim (subs arg (inc idx)))]))) + +(def ^:private struct-field-keys + "Field name → the Penpot keys that may hold it. + + A STRUCT field name is the snake_case of the Penpot key, but a value arrives + with its original key, and some arrive from JSON with the string form. Both + are tried before giving up." + (memoize + (fn [field] + [(keyword (str/replace field "_" "-")) + (keyword field) + field + (str/replace field "_" "-")]))) + +(defn- struct-field + [value field] + (some (fn [k] (when (contains? value k) (get value k))) + (struct-field-keys field))) + +(defn- fixed-vector + "`v` as a plain vector of numbers, for a `DOUBLE[n]` column. + + Records come first because they are what a realized snapshot holds; the map + forms are what a JSON round-trip leaves behind." + [v] + (cond + (gmt/matrix? v) [(:a v) (:b v) (:c v) (:d v) (:e v) (:f v)] + (gpt/point? v) [(:x v) (:y v)] + + ;; A rect: four of the eight fields, the rest being derivable. + (and (map? v) (contains? v :width) (contains? v :height)) + [(:x v) (:y v) (:width v) (:height v)] + + (and (map? v) (contains? v :x) (contains? v :y)) + [(:x v) (:y v)] + + (and (map? v) (contains? v :a) (contains? v :f)) + [(:a v) (:b v) (:c v) (:d v) (:e v) (:f v)] + + (sequential? v) (vec v) + :else nil)) + +(defn- packed-color + "`#RRGGBB` as the packed integer `0xRRGGBBAA`. + + Alpha defaults to opaque: the column holds a colour, and any opacity Penpot + keeps alongside it is a separate attribute." + [v] + (cond + (integer? v) v + (and (string? v) (clr/valid-hex-color? v)) + (let [rgb (Long/parseLong (subs v 1) 16)] + (bit-or (bit-shift-left rgb 8) 0xFF)) + :else nil)) + +(def struct-fields + "`[[field-name field-type] …]` for a STRUCT type, memoized. + + Public because the writers need the same field list to emit a literal." + (memoize (fn [ladybug-type] (vec (parse-struct ladybug-type false))))) + +(def struct-fields-quoted + "`struct-fields` with the DDL's backticks intact. + + Only the Arrow writer wants this: Ladybug names a staged struct's fields from + the Arrow child names and quotes none of them, so a field whose name is a + reserved word — a layout grid cell's `column` — has to arrive already quoted + or `createArrowTable` fails outright." + (memoize (fn [ladybug-type] (vec (parse-struct ladybug-type true))))) + +(def map-types + "`[key-type value-type]` for a MAP type, memoized." + (memoize (fn [ladybug-type] (parse-map ladybug-type)))) + +(def list-element + "Element type of a `T[]` / `T[n]` column, memoized; nil when not a list." + (memoize (fn [ladybug-type] (first (parse-list ladybug-type))))) + +(declare coerce) + +(defn- coerce-struct + [fields v] + (when (map? v) + (into {} + (keep (fn [[field field-type]] + (when-some [fv (struct-field v field)] + [field (coerce field-type fv)]))) + fields))) + +(defn coerce + "`v` as the plain data a column of `ladybug-type` holds. + + Returns `nil` when the value cannot be shaped that way, which callers treat + as \"write NULL\" — a wrong shape in a strongly typed column fails the whole + load, so declining is better than guessing." + [ladybug-type v] + (cond + (nil? v) nil + (not (string? ladybug-type)) v + + (= "UINT32" ladybug-type) (packed-color v) + + ;; Fixed-size numeric arrays are records: matrix, point, rect. + (re-matches #"DOUBLE\[\d+\]" ladybug-type) (fixed-vector v) + + :else + (if-let [[element] (parse-list ladybug-type)] + (when (or (sequential? v) (set? v)) + ;; A set has no order, so its column would otherwise vary between + ;; builds of the same file. Sorting makes it deterministic — which is + ;; what lets two builds be diffed at all, and what a stable golden + ;; needs. Sequential values keep their order: for `shapes` and + ;; `points`, the order *is* the content. + (let [elements (mapv #(coerce element %) v)] + (if (set? v) (vec (sort-by str elements)) elements))) + (if-let [[key-type value-type] (parse-map ladybug-type)] + (when (map? v) + (into {} + (map (fn [[k mv]] [(coerce key-type k) (coerce value-type mv)])) + v)) + (if-let [fields (seq (parse-struct ladybug-type false))] + (coerce-struct fields v) + v))))) diff --git a/backend/src/app/graph/stats.clj b/backend/src/app/graph/stats.clj new file mode 100644 index 0000000000..06a4eb2330 --- /dev/null +++ b/backend/src/app/graph/stats.clj @@ -0,0 +1,48 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.stats + (:require + [app.graph.ladybug :as ladybug] + [app.graph.schema.nodes :as nodes])) + +(defn- count-on-connection + [conn statement] + (or (ladybug/query-scalar-on-connection! conn statement) 0)) + +(defn- rel-table-names + "Relationship tables present in the open database. + + Read from the catalog so a newly ported transform's edges are counted + without this namespace being told about it." + [conn] + (->> (ladybug/query-on-connection! + conn "CALL show_tables() WHERE type = 'REL' RETURN name;" :max-rows 1000) + :rows + (map first))) + +(defn summarize-connection + "Return node/edge counts using an open Ladybug connection." + [conn] + {:nodes (into {} + (map (fn [table] + [table (count-on-connection + conn + (str "MATCH (n:" (nodes/match-label table) ") " + "RETURN count(n) AS " table "_c;"))]) + (map :table nodes/node-types))) + :edges (into {} + (map (fn [rel] + [(keyword rel) + (count-on-connection + conn + (str "MATCH ()-[e:`" rel "`]->() RETURN count(e) AS c;"))])) + (rel-table-names conn))}) + +(defn summarize + "Return node/edge counts from the graph database." + [db-path] + (ladybug/with-connection! db-path summarize-connection)) diff --git a/backend/src/app/graph/sync.clj b/backend/src/app/graph/sync.clj new file mode 100644 index 0000000000..cb32fe2d47 --- /dev/null +++ b/backend/src/app/graph/sync.clj @@ -0,0 +1,900 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.sync + "Incremental Ladybug graph updates from Penpot file-change events." + (:require + [app.common.logging :as l] + [app.common.uuid :as uuid] + [app.graph.ladybug :as ladybug] + [app.graph.projection.document :as projection.document] + [app.graph.schema.nodes :as nodes] + [clojure.string :as str]) + (:import + com.ladybugdb.Connection)) + +(set! *warn-on-reflection* true) + +(def ^:private supported-change-types + #{:add-obj :mod-obj :del-obj + :add-page :del-page :mod-page :mov-objects + :add-component :mod-component :del-component + :restore-component :purge-component}) + +(defn- shape-table + [shape] + (nodes/table-for-type (:type shape))) + +(defn- build-parent-map + [edges] + (into {} + (map (fn [{:keys [from-id to-id to-table]}] + [from-id {:parent-id to-id :parent-table to-table}])) + edges)) + +(defn- build-children-map + [edges] + (reduce (fn [acc {:keys [from-id to-id]}] + (update acc to-id (fnil conj #{}) from-id)) + {} + edges)) + +(defn- resolve-page-id + [shape-id parents pages] + (loop [id shape-id] + (cond + (contains? pages id) id + (get parents id) (recur (:parent-id (parents id))) + :else nil))) + +(defn- node-attrs-id + [attrs] + (cond + (map? attrs) (or (:id attrs) (get attrs "id")) + (and (vector? attrs) (= 2 (count attrs))) + (let [[k v] attrs] + (when (or (= k :id) (= k "id")) v)))) + +(defn- table-rows + "Normalize a projection table value to a vector of attribute maps." + [nodes table] + (let [rows (or (get nodes table) (get nodes (keyword table)))] + (cond + (nil? rows) [] + (map? rows) [rows] + (sequential? rows) (vec rows) + :else []))) + +(defn- document-id-from-nodes + [nodes file-id] + (or (some node-attrs-id (table-rows nodes "Document")) + file-id)) + +(defn- page-index-entry + [attrs] + (let [id (node-attrs-id attrs)] + [id {:id id + :name (:name attrs) + :index (long (:index attrs 0))}])) + +(defn- index-pages + [nodes] + (into {} (map page-index-entry (table-rows nodes "Page")))) + +(defn- component-index-entry + [attrs] + (let [id (node-attrs-id attrs)] + [id {:id id + :name (:name attrs) + :deleted (boolean (:deleted attrs))}])) + +(defn- index-components + [nodes] + (into {} (map component-index-entry (table-rows nodes "Component")))) + +(defn- shape-index-table? + [table] + (not (contains? #{"Document" "Page" "Component" + :Document :Page :Component} + table))) + +(defn- shape-index-entry + [table attrs parents pages edges] + (let [shape-id (node-attrs-id attrs) + {:keys [parent-id parent-table]} (parents shape-id) + edge (first (filter #(= shape-id (:from-id %)) edges))] + [shape-id {:id shape-id + :name (:name attrs) + :table table + :parent-id parent-id + :parent-table parent-table + :position (long (:position edge 0)) + :frame-id (:frame-id attrs) + ;; The projection already denormalized these; re-deriving + ;; page-id from the parent chain would only be a second way to + ;; get the same answer. `:component-ctx` is what later + ;; `:add-obj` children inherit — it is the shape's effective + ;; component-id, which loses the barrier case of a *non-Frame* + ;; carrying its own `component-id` (indistinguishable once + ;; denormalized). Cold projection keeps the distinction. Only a + ;; graph synced across such a shape can drift, and a Reload + ;; rebuilds it. + :component-ctx (:component-id attrs) + :page-id (or (:page-id attrs) + (resolve-page-id shape-id parents pages))}])) + +(defn- index-shapes + [nodes edges parents pages] + (reduce + (fn [acc [table _]] + (into acc (map #(shape-index-entry table % parents pages edges) + (table-rows nodes table)))) + {} + (filter (fn [[table _]] (shape-index-table? table)) nodes))) + +(defn build-index + "Build a sync index from a full graph projection." + [file-id revn {:keys [nodes edges]}] + (let [doc-id (document-id-from-nodes nodes file-id) + pages (index-pages nodes) + components (index-components nodes) + parents (build-parent-map edges) + children-index (build-children-map edges) + shapes (index-shapes nodes edges parents pages)] + {:file-id file-id + :doc-id doc-id + :revn (long revn) + :pages pages + :components components + :shapes shapes + :children children-index})) + + +(defn- format-node-value + [table k v] + (nodes/format-column-value table k v)) + +(defn- create-node-statement + [table attrs] + (let [label (nodes/match-label table) + pairs (for [k (nodes/column-keys table) + :let [v (get attrs k)] + :when (some? v)] + (str (nodes/cypher-property-key table k) ": " + (format-node-value table k v)))] + (str "CREATE (:" label " {" (str/join ", " pairs) "});"))) + +(defn- delete-node-statement + [table shape-id] + (str "MATCH (n:" (nodes/match-label table) " {id: " (ladybug/format-uuid shape-id) "}) " + "DETACH DELETE n;")) + +(defn- create-edge-statement + [{:keys [from-table from-id to-table to-id position]}] + (str "MATCH (s:" (nodes/match-label from-table) " {id: " (ladybug/format-uuid from-id) "}), " + "(p:" (nodes/match-label to-table) " {id: " (ladybug/format-uuid to-id) "}) " + "CREATE (s)-[:IsChildOf {position: " (ladybug/format-int position) "}]->(p);")) + +(defn- create-instance-of-statement + "Link a Frame instance head to its Component. + + No-op when the Component is absent (e.g. library component not ingested)." + [frame-id component-id] + (str "MATCH (f:Frame {id: " (ladybug/format-uuid frame-id) "}), " + "(c:Component {id: " (ladybug/format-uuid component-id) "}) " + "WHERE NOT COALESCE(c.deleted, false) " + "MERGE (f)-[:IsInstanceOf]->(c);")) + +(defn- delete-instance-of-statement + [frame-id] + (str "MATCH (f:Frame {id: " (ladybug/format-uuid frame-id) "})" + "-[r:IsInstanceOf]->(:Component) " + "DELETE r;")) + +(defn- instance-of-statements + "Cypher to (re)link `IsInstanceOf` after add/mod of a Frame's component-id." + [table shape-id component-id] + (when (= table "Frame") + (cond-> [(delete-instance-of-statement shape-id)] + (some? component-id) + (conj (create-instance-of-statement shape-id component-id))))) + +(defn- delete-edge-statement + [{:keys [from-table from-id to-table to-id]}] + (str "MATCH (s:" (nodes/match-label from-table) " {id: " (ladybug/format-uuid from-id) "})" + "-[r:IsChildOf]->" + "(p:" (nodes/match-label to-table) " {id: " (ladybug/format-uuid to-id) "}) " + "DELETE r;")) + +(defn- set-edge-position-statement + [{:keys [from-table from-id to-table to-id position]}] + (str "MATCH (s:" (nodes/match-label from-table) " {id: " (ladybug/format-uuid from-id) "})" + "-[r:IsChildOf]->" + "(p:" (nodes/match-label to-table) " {id: " (ladybug/format-uuid to-id) "}) " + "SET r.position = " (ladybug/format-int position) ";")) + +(defn- set-node-attr-statement + [table shape-id attr value] + (str "MATCH (s:" (nodes/match-label table) " {id: " (ladybug/format-uuid shape-id) "}) " + "SET s." (nodes/cypher-property-key table attr) " = " + (format-node-value table attr value) ";")) + +(defn- set-page-name-statement + [page-id name] + (str "MATCH (p:Page {id: " (ladybug/format-uuid page-id) "}) " + "SET p.name = " (ladybug/format-string name) ";")) + +(defn- remove-node-attr-statement + "Clear a property. Ladybug has no Neo4j-style REMOVE; SET to NULL." + [table shape-id attr] + (str "MATCH (s:" (nodes/match-label table) " {id: " (ladybug/format-uuid shape-id) "}) " + "SET s." (nodes/cypher-property-key table attr) " = NULL;")) + +(defn- index-add-component! + [index {:keys [id name doc-id]}] + (-> index + (assoc-in [:components id] {:id id :name name :deleted false}) + (update :children update doc-id (fnil conj #{}) id))) + +(defn- index-remove-component! + [index component-id] + (let [doc-id (:doc-id index)] + (-> index + (update :components dissoc component-id) + (update :children update doc-id #(disj (or % #{}) component-id))))) + + +(defn- set-document-revision-statement + "Set the Document's revision number. + + `app.graph.schema.contract` names the column `revision`, not `revn`. The name + is produced by `nodes/cypher-property-key`, so this statement and the DDL + cannot disagree." + [doc-id revn] + (str "MATCH (d:Document {id: " (ladybug/format-uuid doc-id) "}) " + "SET d." (nodes/cypher-property-key "Document" :revn) " = " + (ladybug/format-int revn) ";")) + +(defn- resolve-parent-for-add + [index {:keys [parent-id frame-id page-id]}] + (let [pid (or parent-id frame-id)] + (if (or (nil? pid) (uuid/zero? pid)) + (when page-id + {:parent-id page-id :parent-table "Page"}) + (if-let [shape (get-in index [:shapes pid])] + {:parent-id pid :parent-table (:table shape)} + (when (get-in index [:pages pid]) + {:parent-id pid :parent-table "Page"}))))) + +(defn- index-add-shape! + [index {:keys [id name table parent-id parent-table position page-id + frame-id component-ctx]}] + (-> index + (assoc-in [:shapes id] + {:id id + :name name + :table table + :parent-id parent-id + :parent-table parent-table + :position position + :frame-id frame-id + :component-ctx component-ctx + :page-id page-id}) + (update :children update parent-id (fnil conj #{}) id))) + +(defn- index-remove-shape! + [index shape-id] + (if-let [shape (get-in index [:shapes shape-id])] + (-> index + (update :shapes dissoc shape-id) + (update :children update (:parent-id shape) + #(disj (or % #{}) shape-id)) + (update :children dissoc shape-id)) + index)) + +(defn- index-add-page! + [index {:keys [id name doc-id] page-index :index}] + (-> index + (assoc-in [:pages id] {:id id :name name :index page-index}) + (update :children update doc-id (fnil conj #{}) id))) + +(defn- index-move-shape! + [index shape-id {:keys [parent-id parent-table position page-id frame-id]}] + (let [old-parent (get-in index [:shapes shape-id :parent-id])] + (-> index + (assoc-in [:shapes shape-id :parent-id] parent-id) + (assoc-in [:shapes shape-id :parent-table] parent-table) + (assoc-in [:shapes shape-id :position] position) + (assoc-in [:shapes shape-id :frame-id] frame-id) + (cond-> page-id (assoc-in [:shapes shape-id :page-id] page-id)) + (update :children update old-parent #(disj (or % #{}) shape-id)) + (update :children update parent-id (fnil conj #{}) shape-id)))) + +;; --- the columns that restate parenthood +;; +;; A shape carries `parent_id` and `frame_id`, and a container carries the +;; ordered `shapes` list. All three restate what `IsChildOf` already says, and +;; the cold projection writes them from the file, so this path has to keep +;; them in step or a synced graph stops matching a rebuilt one. + +(defn- shape-parent-id + "The `parent_id` a shape's own column holds. + + A top-level shape's parent in the file is the page's root frame, which the + graph does not materialize, so `IsChildOf` points at the Page while the + column holds `uuid/zero`." + [parent-id parent-table] + (if (= "Page" parent-table) uuid/zero parent-id)) + +(defn- frame-id-under + "The `frame_id` a shape gets when its parent is `parent-id`. + + Penpot's rule, from `app.common.files.changes` `:mov-objects`: the parent + itself when the parent is a Frame, the parent's own frame otherwise." + [index parent-id parent-table] + (cond + (= "Page" parent-table) uuid/zero + (= "Frame" parent-table) parent-id + :else (get-in index [:shapes parent-id :frame-id] uuid/zero))) + +(defn- frame-id-updates + "`[shape-id frame-id]` for a moved shape and everything that follows it. + + A Frame keeps its descendants pointing at itself, so the walk stops there. + Any other shape carries its subtree onto the new frame." + [index shape-id frame-id] + (into [[shape-id frame-id]] + (when (not= "Frame" (get-in index [:shapes shape-id :table])) + (mapcat #(frame-id-updates index % frame-id) + (get-in index [:children shape-id] #{}))))) + +(defn- child-shapes-value + "A container's stored `shapes` list, rebuilt from the index. + + `IsChildOf.position` counts from the last entry of that list + (`app.graph.projection.document/child-shape-ids` reverses it), so reversing the + children ordered by position gives the list back." + [index parent-id] + (->> (get-in index [:children parent-id] #{}) + (sort-by #(get-in index [:shapes % :position] 0)) + reverse + vec)) + +(defn- insert-position + "The graph position the lowest of `k` shapes takes when they are inserted + into a parent that already holds `n-before` children. + + A container's stored `:shapes` list runs bottom to top, and the graph + numbers children in Penpot z-order, so the two run opposite ways. An append + to the stored list, which is what `:add-obj` does without an `:index`, is + therefore position 0 and pushes every sibling up by one. The block occupies + the result and the `k - 1` positions above it, the first shape highest." + [n-before {:keys [index]} after-position] + (cond + (some? after-position) (long after-position) + (some? index) (max 0 (- n-before (long index))) + :else 0)) + +(defn- renumber-siblings + "Shift `parent-id`'s children at or above `from` by `delta`. + + Returns `[index statements]`. `except` names children the caller is placing + itself." + [index parent-id parent-table from delta except] + (reduce + (fn [[idx stmts] child-id] + (let [pos (get-in idx [:shapes child-id :position])] + (if (and (some? pos) (not (contains? except child-id)) (>= (long pos) (long from))) + (let [pos' (+ (long pos) (long delta))] + [(assoc-in idx [:shapes child-id :position] pos') + (conj stmts (set-edge-position-statement + {:from-table (get-in idx [:shapes child-id :table]) + :from-id child-id + :to-table parent-table + :to-id parent-id + :position pos'}))]) + [idx stmts]))) + [index []] + (vec (get-in index [:children parent-id] #{})))) + +(defn- set-children-statements + "Refresh the `shapes` column of every container in `parent-ids`. + + A Page has no such column: its top-level shapes hang off a root frame the + graph never materializes." + [index parent-ids] + (into [] + (comp (distinct) + (keep (fn [parent-id] + (let [table (get-in index [:shapes parent-id :table])] + (when (contains? nodes/container-tables table) + (set-node-attr-statement + table parent-id :shapes + (child-shapes-value index parent-id))))))) + parent-ids)) + +(defn- mov-object-ids + [shapes] + (let [coll (cond + (nil? shapes) [] + (sequential? shapes) shapes + (uuid? shapes) [shapes] + (map? shapes) (if-let [id (or (:id shapes) (get shapes "id"))] + [id] + []) + :else [])] + (into [] + (keep (fn [shape] + (when shape + (if (uuid? shape) shape (:id shape))))) + coll))) + +(defn- detach-shape + "Take `shape-id` out of its current parent and close the gap it leaves. + + Returns `[index statements]`. The edge itself is left alone: the caller + either replaces it or deletes it." + [index shape-id] + (let [{:keys [parent-id parent-table position]} (get-in index [:shapes shape-id]) + index (update-in index [:children parent-id] #(disj (or % #{}) shape-id)) + [index stmts] (renumber-siblings index parent-id parent-table + (inc (long (or position 0))) -1 #{})] + [(assoc-in index [:shapes shape-id :position] nil) stmts])) + +(defn- apply-mov-objects + [index {:keys [shapes page-id] :as change}] + (let [shape-ids (mov-object-ids shapes) + parent (resolve-parent-for-add index + (assoc change + :frame-id (:parent-id change) + :page-id page-id))] + (cond + (empty? shape-ids) + {:index index :statements [] :applied? true} + + (not parent) + {:index index :statements [] :applied? false :reason :missing-parent} + + :else + (let [parent-id (:parent-id parent) + parent-table (:parent-table parent) + page-id' (or page-id + (when (= parent-table "Page") parent-id) + (get-in index [:shapes (first shape-ids) :page-id])) + known (filterv #(get-in index [:shapes %]) shape-ids) + old-parents (mapv #(get-in index [:shapes % :parent-id]) known) + ;; Penpot removes the shapes from wherever they were, then inserts + ;; the block into the target, so the target's width is measured + ;; after the removals. + [index detach-stmts] + (reduce (fn [[idx stmts] shape-id] + (let [[idx' s] (detach-shape idx shape-id)] + [idx' (into stmts s)])) + [index []] + known) + n-before (count (get-in index [:children parent-id] #{})) + after-pos (get-in index [:shapes (:after-shape change) :position]) + lowest (insert-position n-before change after-pos) + k (count known) + [index shift-stmts] + (renumber-siblings index parent-id parent-table lowest k #{})] + (loop [index index + statements (into detach-stmts shift-stmts) + entries (map-indexed vector known)] + (if-let [[offset shape-id] (first entries)] + (let [shape (get-in index [:shapes shape-id]) + position (+ lowest (- k 1 (long offset))) + frame-id (frame-id-under index parent-id parent-table) + frame-writes (frame-id-updates index shape-id frame-id) + edge {:from-table (:table shape) + :from-id shape-id + :to-table parent-table + :to-id parent-id + :position position} + moved? (not= parent-id (:parent-id shape)) + statements (-> statements + (cond-> moved? + (conj (delete-edge-statement + {:from-table (:table shape) + :from-id shape-id + :to-table (:parent-table shape) + :to-id (:parent-id shape)}))) + (conj (if moved? + (create-edge-statement edge) + (set-edge-position-statement edge)))) + ;; The shape's own columns restate the edge, and the frame + ;; follows the whole subtree the shape carries with it. + statements (if-not moved? + statements + (into (conj statements + (set-node-attr-statement + (:table shape) shape-id :parent-id + (shape-parent-id parent-id parent-table))) + (map (fn [[sid fid]] + (set-node-attr-statement + (get-in index [:shapes sid :table]) + sid :frame-id fid))) + frame-writes)) + index (index-move-shape! index shape-id + {:parent-id parent-id + :parent-table parent-table + :position position + :frame-id frame-id + :page-id page-id'}) + index (reduce (fn [idx [sid fid]] + (assoc-in idx [:shapes sid :frame-id] fid)) + index + frame-writes)] + (recur index statements (rest entries))) + {:index index + :statements (into statements + (set-children-statements index (conj old-parents parent-id))) + :applied? true})))))) + +(defn- index-remove-page! + [index page-id] + (let [doc-id (:doc-id index)] + (-> index + (update :pages dissoc page-id) + (update :children update doc-id #(disj (or % #{}) page-id)) + (update :children dissoc page-id)))) + +(defn- mod-attrs-for-table + [table] + (disj (set (nodes/column-keys table)) :id)) + +(defn- apply-add-obj + [index change] + (let [{:keys [id obj page-id]} change + table (shape-table obj)] + (if-not table + {:index index :statements [] :applied? false :reason :unsupported-shape-type} + (let [parent (resolve-parent-for-add index change)] + (if-not parent + {:index index :statements [] :applied? false :reason :missing-parent} + (let [parent-id (:parent-id parent) + parent-table (:parent-table parent) + n-before (count (get-in index [:children parent-id] #{})) + position (insert-position n-before change nil) + [index shift-stmts] + (renumber-siblings index parent-id parent-table position 1 #{}) + ;; The same denormalizations the cold projection performs, so + ;; a live-synced graph and a rebuilt one carry equal columns. + resolved-page-id + (or page-id + (when (= parent-table "Page") parent-id) + (get-in index [:shapes parent-id :page-id])) + parent-ctx (get-in index [:shapes parent-id :component-ctx]) + shape (projection.document/denormalized-shape + (assoc obj :id id) resolved-page-id parent-ctx) + attrs (nodes/project-attrs table shape) + edge {:from-table table + :from-id id + :to-table parent-table + :to-id parent-id + :position position} + stmts (-> shift-stmts + (conj (create-node-statement table attrs)) + (conj (create-edge-statement edge)) + (into (instance-of-statements table id (:component-id attrs)))) + index' (index-add-shape! index + {:id id + :name (:name attrs) + :table table + :parent-id parent-id + :parent-table parent-table + :position position + :frame-id (:frame-id attrs) + :component-ctx (projection.document/descend-component-ctx + table shape parent-ctx) + :page-id resolved-page-id})] + {:index index' + :statements (into stmts (set-children-statements index' [parent-id])) + :applied? true})))))) + +(defn- apply-mod-obj + [index {:keys [id operations]}] + (if-let [shape (get-in index [:shapes id])] + (let [table (:table shape) + syncable (mod-attrs-for-table table) + set-ops (filter #(and (= :set (:type %)) + (contains? syncable (:attr %))) + operations)] + (if (empty? set-ops) + {:index index :statements [] :applied? false :reason :unsupported-operations} + (let [updates (into {} (map (juxt :attr :val) set-ops)) + statements + (into (vec (for [[attr value] updates] + (set-node-attr-statement table id attr value))) + ;; Relink when component-id is among the synced attrs. + (when (contains? updates :component-id) + (instance-of-statements table id (:component-id updates)))) + index' (reduce (fn [idx [attr value]] + (assoc-in idx [:shapes id attr] value)) + index + updates)] + {:index index' + :statements statements + :applied? true}))) + {:index index :statements [] :applied? false :reason :missing-shape})) + +(defn- delete-order-deepest-first + [children root-id] + (letfn [(post-order [id] + (into (mapcat post-order (get children id #{})) + [id]))] + (post-order root-id))) + +(defn- apply-del-obj + [index {:keys [id]}] + (if-let [root (get-in index [:shapes id])] + (let [to-delete (delete-order-deepest-first (:children index) id) + statements + (vec (mapcat (fn [shape-id] + (let [{:keys [table parent-id parent-table]} + (get-in index [:shapes shape-id])] + [(delete-edge-statement + {:from-table table + :from-id shape-id + :to-table parent-table + :to-id parent-id}) + (delete-node-statement table shape-id)])) + to-delete)) + index' (reduce index-remove-shape! index to-delete) + ;; Only the deleted subtree's own parent survives to be renumbered: + ;; every other parent in `to-delete` goes with it. + [index' shift-stmts] + (renumber-siblings index' (:parent-id root) (:parent-table root) + (inc (long (or (:position root) 0))) -1 #{})] + {:index index' + :statements (-> statements + (into shift-stmts) + (into (set-children-statements index' [(:parent-id root)]))) + :applied? true}) + ;; Penpot emits one :del-obj per selected shape; an earlier change in the + ;; same batch may have already removed this node (e.g. parent + child). + {:index index :statements [] :applied? true})) + +(defn- apply-add-page + [index {:keys [id name page]}] + (let [page-id (or id (:id page)) + page (or page {:id page-id :name name}) + page (nodes/project-attrs "Page" {:id page-id + :name (or (:name page) "Page") + :index (count (:pages index))}) + doc-id (:doc-id index) + position (count (:pages index)) + edge {:from-table "Page" + :from-id page-id + :to-table "Document" + :to-id doc-id + :position position}] + {:index (index-add-page! index + {:id page-id + :name (:name page) + :index (:index page) + :doc-id doc-id}) + :statements [(create-node-statement "Page" page) + (create-edge-statement edge)] + :applied? true})) + +(defn- apply-del-page + [index {:keys [id]}] + (if (get-in index [:pages id]) + (let [shape-ids (into #{} + (comp (filter #(= id (get-in index [:shapes % :page-id]))) + (filter #(= "Page" (get-in index [:shapes % :parent-table])))) + (keys (:shapes index))) + del-shapes + (reduce (fn [acc shape-id] + (let [result (apply-del-obj acc {:type :del-obj :id shape-id})] + (if (:applied? result) + (-> acc + (assoc :index (:index result)) + (update :statements into (:statements result))) + acc))) + {:index index :statements []} + shape-ids) + statements + (conj (:statements del-shapes) + (delete-edge-statement {:from-table "Page" + :from-id id + :to-table "Document" + :to-id (:doc-id index)}) + (delete-node-statement "Page" id))] + {:index (-> (:index del-shapes) (index-remove-page! id)) + :statements statements + :applied? true}) + {:index index :statements [] :applied? false :reason :missing-page})) + +(defn- apply-mod-page + [index {:keys [id name]}] + (if (and (string? name) (get-in index [:pages id])) + {:index (assoc-in index [:pages id :name] name) + :statements [(set-page-name-statement id name)] + :applied? true} + {:index index :statements [] :applied? false :reason :unsupported-page-change})) + +(defn- component-syncable-attrs + "Projected Component columns that sync may SET (everything but :id)." + [] + (disj (set (nodes/column-keys "Component")) :id)) + +(defn- component-attrs-from-change + "Build CREATE attrs for `:add-component` (objects are not projected)." + [{:keys [id name path main-instance-id main-instance-page + annotation variant-id variant-properties]}] + (cond-> {:id id + :name (or name "Component") + :path (or path "") + :main-instance-id main-instance-id + :main-instance-page main-instance-page} + (some? annotation) (assoc :annotation annotation) + (some? variant-id) (assoc :variant-id variant-id) + (seq variant-properties) (assoc :variant-properties variant-properties))) + +(defn- apply-add-component + [index {:keys [id] :as change}] + (if (get-in index [:components id]) + {:index index :statements [] :applied? true} + (let [doc-id (:doc-id index) + position (count (:components index)) + attrs (nodes/project-attrs "Component" (component-attrs-from-change change)) + edge {:from-table "Component" + :from-id id + :to-table "Document" + :to-id doc-id + :position position}] + {:index (index-add-component! index + {:id id + :name (:name attrs) + :doc-id doc-id}) + :statements [(create-node-statement "Component" attrs) + (create-edge-statement edge)] + :applied? true}))) + +(defn- apply-mod-component + "Update projected Component attrs from a `:mod-component` change. + + Nil optional values clear the property (Penpot dissocs them). `:objects` + is never projected — shape trees live on pages." + [index {:keys [id] :as change}] + (let [syncable (component-syncable-attrs) + sets (into {} + (keep (fn [[k v]] + (when (and (contains? syncable k) (some? v)) + [k v]))) + (dissoc change :type :id :objects)) + removes (into [] + (keep (fn [[k v]] + (when (and (contains? syncable k) (nil? v)) + k))) + (dissoc change :type :id :objects)) + stmts (into (mapv (fn [[k v]] + (set-node-attr-statement "Component" id k v)) + sets) + (map #(remove-node-attr-statement "Component" id %) removes)) + index' (if (get-in index [:components id]) + (cond-> index + (contains? sets :name) + (assoc-in [:components id :name] (:name sets))) + (assoc-in index [:components id] + {:id id + :name (:name sets) + :deleted false}))] + (if (empty? stmts) + {:index index :statements [] :applied? true} + {:index index' :statements stmts :applied? true}))) + +(defn- apply-del-component + [index {:keys [id skip-undelete?]}] + (cond + (not (get-in index [:components id])) + {:index index :statements [] :applied? true} + + skip-undelete? + {:index (index-remove-component! index id) + :statements [(delete-edge-statement {:from-table "Component" + :from-id id + :to-table "Document" + :to-id (:doc-id index)}) + (delete-node-statement "Component" id)] + :applied? true} + + :else + {:index (assoc-in index [:components id :deleted] true) + :statements [(set-node-attr-statement "Component" id :deleted true)] + :applied? true})) + +(defn- apply-restore-component + [index {:keys [id page-id]}] + (let [stmts (cond-> [(set-node-attr-statement "Component" id :deleted false)] + page-id + (conj (set-node-attr-statement "Component" id :main-instance-page page-id))) + index (if (get-in index [:components id]) + (-> index + (assoc-in [:components id :deleted] false) + (cond-> page-id + (assoc-in [:components id :main-instance-page] page-id))) + (assoc-in index [:components id] + {:id id :name nil :deleted false}))] + {:index index :statements stmts :applied? true})) + +(defn- apply-purge-component + [index {:keys [id]}] + (if-not (get-in index [:components id]) + ;; Still attempt delete in case the node exists but was not indexed. + {:index index + :statements [(delete-edge-statement {:from-table "Component" + :from-id id + :to-table "Document" + :to-id (:doc-id index)}) + (delete-node-statement "Component" id)] + :applied? true} + {:index (index-remove-component! index id) + :statements [(delete-edge-statement {:from-table "Component" + :from-id id + :to-table "Document" + :to-id (:doc-id index)}) + (delete-node-statement "Component" id)] + :applied? true})) + +(defn- apply-change + [index change] + (case (:type change) + :add-obj (apply-add-obj index change) + :mod-obj (apply-mod-obj index change) + :del-obj (apply-del-obj index change) + :add-page (apply-add-page index change) + :del-page (apply-del-page index change) + :mod-page (apply-mod-page index change) + :mov-objects (apply-mov-objects index change) + :add-component (apply-add-component index change) + :mod-component (apply-mod-component index change) + :del-component (apply-del-component index change) + :restore-component (apply-restore-component index change) + :purge-component (apply-purge-component index change) + {:index index :statements [] :applied? false :reason :unsupported-type})) + +(defn apply-changes! + "Apply Penpot `changes` to an open Ladybug `conn` and return the updated index. + + Returns `{:index ... :revn ... :applied [...] :skipped [...]}`." + [^Connection conn index changes revn] + (when (> (long revn) (:revn index)) + (l/wrn :hint "graph sync revn gap" + :file-id (str (:file-id index)) + :index-revn (:revn index) + :change-revn revn)) + (loop [index index + applied [] + skipped [] + stmts [] + changes (seq changes)] + (if-let [change (first changes)] + (let [{:keys [index statements applied? reason]} + (apply-change index change)] + (recur index + (cond-> applied applied? (conj (:type change))) + (cond-> skipped (not applied?) (conj {:type (:type change) :reason reason})) + (cond-> stmts applied? (into statements)) + (rest changes))) + (let [final-stmts (cond-> stmts + (and (seq applied) (:doc-id index)) + (conj (set-document-revision-statement (:doc-id index) revn))) + index' (if (seq applied) + (assoc index :revn (long revn)) + index)] + (when (seq final-stmts) + (ladybug/exec-on-connection! conn final-stmts)) + {:index index' + :revn (if (seq applied) (long revn) (:revn index')) + :applied applied + :skipped skipped})))) + +(defn supported-change? + [change] + (contains? supported-change-types (:type change))) diff --git a/backend/src/app/http.clj b/backend/src/app/http.clj index e991fd9849..d3496a58e5 100644 --- a/backend/src/app/http.clj +++ b/backend/src/app/http.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http (:require diff --git a/backend/src/app/http/access_token.clj b/backend/src/app/http/access_token.clj index 02d877b1df..57f19fdda8 100644 --- a/backend/src/app/http/access_token.clj +++ b/backend/src/app/http/access_token.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.access-token (:require diff --git a/backend/src/app/http/assets.clj b/backend/src/app/http/assets.clj index 1458b06d27..5c35e9dbf4 100644 --- a/backend/src/app/http/assets.clj +++ b/backend/src/app/http/assets.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.assets "Assets related handlers." @@ -14,6 +14,7 @@ [app.db :as db] [app.http.access-token :as actoken] [app.http.session :as session] + [app.rpc.permissions :as perms] [app.storage :as sto] [integrant.core :as ig] [yetti.response :as-alias yres])) @@ -40,20 +41,38 @@ (ex/raise :type :not-found :hint "object not found"))) +(defn- get-share-id + "Extract and validate the optional `share-id` query param. Returns a UUID + or `nil` for missing/malformed values." + [{:keys [query-params]}] + (some-> query-params :share-id d/parse-uuid)) + (defn- get-file-media-object [pool id] - (db/get pool :file-media-object {:id id} {::db/remove-deleted false})) + (db/get* pool :file-media-object {:id id} {::db/remove-deleted false})) (defn- serve-object-from-s3 [{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj] (let [sig-max-age (or signature-max-age default-signature-max-age) cch-max-age (or cache-max-age default-cache-max-age) - {:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})] + bucket (-> obj meta :bucket) + public? (contains? public-buckets bucket) + ;; The disposition is also signed into the presigned url: this + ;; response is a redirect, so the header below applies to the + ;; redirect itself and not to the bytes the client then fetches + ;; from the object store. + {:keys [host port] :as url} (sto/get-object-url storage obj + (cond-> {:max-age sig-max-age} + (not public?) + (assoc :content-disposition "attachment"))) + headers (cond-> {"location" (str url) + "x-host" (cond-> host port (str ":" port)) + "x-mtype" (-> obj meta :content-type) + "cache-control" (str "max-age=" (inst-ms cch-max-age))} + (not public?) + (assoc "content-disposition" "attachment"))] {::yres/status 307 - ::yres/headers {"location" (str url) - "x-host" (cond-> host port (str ":" port)) - "x-mtype" (-> obj meta :content-type) - "cache-control" (str "max-age=" (inst-ms cch-max-age))}})) + ::yres/headers headers})) (defn- serve-object-from-fs [{:keys [::path ::cache-max-age]} obj] @@ -61,9 +80,12 @@ purl (u/join (u/uri path) (sto/object->relative-path obj)) mdata (meta obj) - headers {"x-accel-redirect" (:path purl) - "content-type" (:content-type mdata) - "cache-control" (str "max-age=" (inst-ms cch-max-age))}] + bucket (:bucket mdata) + headers (cond-> {"x-accel-redirect" (:path purl) + "content-type" (:content-type mdata) + "cache-control" (str "max-age=" (inst-ms cch-max-age))} + (not (contains? public-buckets bucket)) + (assoc "content-disposition" "attachment"))] {::yres/status 204 ::yres/headers headers})) @@ -81,17 +103,32 @@ (let [bucket (-> obj meta :bucket)] (not (contains? public-buckets bucket)))) +(defn- request-profile-id + "Extract the authenticated profile-id from the request." + [request] + (or (::session/profile-id request) + (::actoken/profile-id request))) + (defn- authenticated? "Check if the request has an authenticated profile, either via session or access token." [request] - (or (some? (::session/profile-id request)) - (some? (::actoken/profile-id request)))) + (some? (request-profile-id request))) + +(defn- tempfile-owner-match? + "Check if the request's profile-id matches the tempfile's stored owner. + Returns true if no profile-id was stored (legacy objects)." + [obj request] + (let [stored-profile-id (:profile-id (meta obj)) + request-profile-id (request-profile-id request)] + (or (nil? stored-profile-id) + (= stored-profile-id request-profile-id)))) (defn objects-handler "Handler that serves storage objects by id. For non-public buckets (e.g. profile), requires authentication - via session cookie or access token." + via session cookie or access token. + For tempfile bucket, also requires ownership (profile-id match)." [{:keys [::sto/storage] :as cfg} request] (let [id (get-id request) obj (sto/get-object storage id)] @@ -103,19 +140,32 @@ (not (authenticated? request))) {::yres/status 401} + (and (= (-> obj meta :bucket) sto/tempfile-bucket) + (not (tempfile-owner-match? obj request))) + {::yres/status 404} + :else (serve-object cfg obj)))) (defn- generic-handler "A generic handler helper/common code for file-media based handlers." [{:keys [::sto/storage] :as cfg} request kf] - (let [pool (::db/pool storage) - id (get-id request) - mobj (get-file-media-object pool id) - sobj (sto/get-object storage (kf mobj))] - (if sobj - (serve-object cfg sobj) - {::yres/status 404}))) + (let [pool (::db/pool storage) + id (get-id request) + mobj (get-file-media-object pool id)] + (if (nil? mobj) + {::yres/status 404} + (let [file-id (:file-id mobj) + profile-id (or (::session/profile-id request) + (::actoken/profile-id request)) + share-id (get-share-id request) + perms (perms/get-file-read-permissions pool profile-id file-id share-id)] + (if-not (:can-read perms) + {::yres/status 404} + (let [sobj (sto/get-object storage (kf mobj))] + (if sobj + (serve-object cfg sobj) + {::yres/status 404}))))))) (defn file-objects-handler "Handler that serves storage objects by file media id." diff --git a/backend/src/app/http/awsns.clj b/backend/src/app/http/awsns.clj index 3dddc1045d..a3c2f03ec7 100644 --- a/backend/src/app/http/awsns.clj +++ b/backend/src/app/http/awsns.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.awsns "AWS SNS webhook handler for bounces." diff --git a/backend/src/app/http/client.clj b/backend/src/app/http/client.clj index bba77f9aa0..db5ec6ffbe 100644 --- a/backend/src/app/http/client.clj +++ b/backend/src/app/http/client.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.client "Http client abstraction layer. diff --git a/backend/src/app/http/debug.clj b/backend/src/app/http/debug.clj index 26cac774f6..edcef0bb50 100644 --- a/backend/src/app/http/debug.clj +++ b/backend/src/app/http/debug.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.debug (:refer-clojure :exclude [error-handler]) @@ -13,6 +13,10 @@ [app.common.data :as d] [app.common.exceptions :as ex] [app.common.features :as cfeat] + [app.common.files.changes :as cfc] + [app.common.files.repair :as cfr] + [app.common.files.validate :as cfv] + [app.common.json :as json] [app.common.logging :as l] [app.common.pprint :as pp] [app.common.time :as ct] @@ -22,17 +26,20 @@ [app.db :as db] [app.features.file-migrations :as feat.fmig] [app.http.session :as session] + [app.redis :as rds] [app.rpc.commands.auth :as auth] [app.rpc.commands.files-create :refer [create-file]] [app.rpc.commands.profile :as profile] [app.rpc.commands.teams :as teams] [app.setup :as-alias setup] [app.setup.clock :as clock] + [app.srepl.helpers :as h] [app.srepl.main :as srepl] [app.storage :as-alias sto] [app.storage.tmp :as tmp] [app.util.template :as tmpl] [cuerdas.core :as str] + [datoteka.fs :as fs] [datoteka.io :as io] [emoji.core :as emj] [integrant.core :as ig] @@ -47,21 +54,66 @@ ;; INDEX ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(def ^:private max-export-jobs 200) + +(defn- scan-export-job-keys + "Note: no index for now, get them all and filter" + [conn pattern] + (loop [cursor "0" + found []] + (let [[cursor keys] (rds/scan conn cursor pattern max-export-jobs) + found (into found keys)] + (if (or (nil? cursor) + (= "0" cursor) + (>= (count found) max-export-jobs)) + (into [] (take max-export-jobs) found) + (recur cursor found))))) + +(defn- get-export-jobs + [cfg job-id] + (let [filtered? (not (str/empty-or-nil? job-id)) + job-uuid (when filtered? (parse-uuid job-id))] + (if (and filtered? (nil? job-uuid)) + [] + (try + (let [pattern (str "penpot.exporter." (cf/get :tenant) ".job." (or job-uuid "*"))] + (->> (rds/run! cfg (fn [{:keys [::rds/conn]}] + (->> (scan-export-job-keys conn pattern) + (mapv (fn [key] (rds/hget conn key "data")))))) + (keep (fn [blob] + (try + (t/decode-str blob) + (catch Throwable _ nil)))) + (sort-by :created-at #(compare %2 %1)) + ;; The exporter stores instants as epoch millis. + (map (fn [{:keys [created-at ended-at] :as job}] + (-> job + (assoc :created-at (some-> created-at ct/inst (ct/format-inst :rfc1123))) + (assoc :ended-at (some-> ended-at ct/inst (ct/format-inst :rfc1123)))))) + (vec))) + (catch Throwable cause + (l/warn :hint "unable to read export jobs" :cause cause) + []))))) + (defn index-handler [cfg request] (let [profile-id (::session/profile-id request) offset (clock/get-offset profile-id) - profile (profile/get-profile cfg profile-id)] + profile (profile/get-profile cfg profile-id) + job-filter (some-> request :params :job-id str/trim)] {::yres/status 200 ::yres/headers {"content-type" "text/html"} ::yres/body (-> (io/resource "app/templates/debug.tmpl") (tmpl/render {:version (:full cf/version) :profile profile + :graph-enabled (contains? cf/flags :graph) :current-clock ct/*clock* :current-offset (if offset (ct/format-duration offset) "NO OFFSET") :current-time (ct/format-inst (ct/now) :http) + :export-jobs (get-export-jobs cfg job-filter) + :export-job-filter job-filter :supported-features cfeat/supported-features}))})) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -130,7 +182,7 @@ :hint "invalid button")) (ex/raise :type :not-found - :code :enpty-data + :code :empty-data :hint "empty response")))) (defn- is-file-exists? @@ -314,11 +366,17 @@ (if clone? (let [profile (profile/get-profile pool profile-id) project-id (:default-project-id profile) + team (teams/get-team pool + :profile-id profile-id + :project-id project-id) cfg (assoc cfg ::bfc/overwrite false ::bfc/profile-id profile-id ::bfc/project-id project-id - ::bfc/input path)] + ::bfc/team-id (:id team) + ::bfc/input path + ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size) + ::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))] (bf.v3/import-files! cfg) {::yres/status 200 ::yres/headers {"content-type" "text/plain"} @@ -330,6 +388,226 @@ "content-disposition" (str "attachmen; filename=" (first file-ids) ".penpot")}})))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; GRAPH (flag: :graph) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; `app.graph.*` resolves at call time, never at the top of this namespace. +;; `app.graph.ladybug` imports `com.ladybugdb.*`, so requiring it links the +;; Ladybug native library into the JVM, and this namespace loads on every +;; backend boot. The routes below are registered only under the `:graph` flag, +;; so with the flag off nothing resolves and no native code loads. + +(defn- graph-export-file + "Path of a freshly projected graph for `file-id`." + [cfg file-id] + (let [ingest-file! (requiring-resolve 'app.graph.ingest/ingest-file!) + {:keys [db-path]} (ingest-file! cfg file-id :skip-stats? true)] + (when-not (fs/exists? db-path) + (ex/raise :type :internal + :code :graph-file-not-found + :hint "graph database file missing after ingest" + :file-id (str file-id) + :db-path db-path)) + db-path)) + +(defn- graph-export-session + "Path of a snapshot of the caller's live in-memory graph for `file-id`." + [profile-id file-id] + (let [session-info (requiring-resolve 'app.graph.debug/session-info) + export-session-database! (requiring-resolve 'app.graph.debug/export-session-database!) + info (session-info profile-id)] + (when-not info + (ex/raise :type :not-found + :code :graph-session-not-loaded + :hint "no in-memory graph is loaded; load one first, or use source=file")) + (when-not (= file-id (:file-id info)) + (ex/raise :type :validation + :code :graph-session-file-mismatch + :hint "the loaded session holds a different file" + :requested (str file-id) + :loaded (str (:file-id info)))) + (export-session-database! profile-id))) + +(defn graph-export-handler + "Stream a Ladybug `.lbug` database for a file. + + `source=file` (default) projects the file afresh from the database — the + reproducible artifact. `source=session` snapshots the caller's live + in-memory console graph instead, which live-sync may have moved away from a + fresh projection; taking that away to query it elsewhere is the whole point + of asking for it. Synchronous on each request." + [cfg {:keys [params] :as request}] + (let [file-id (some-> params :file-id parse-uuid) + source (or (some-> params :source str/lower) "file")] + (when-not file-id + (ex/raise :type :validation + :code :missing-arguments + :hint "missing file-id")) + (when-not (contains? #{"file" "session"} source) + (ex/raise :type :validation + :code :invalid-arguments + :hint "source must be 'file' or 'session'" + :source source)) + + (let [session? (= "session" source) + db-path (if session? + (graph-export-session (::session/profile-id request) file-id) + (graph-export-file cfg file-id))] + {::yres/status 200 + ;; A session export is a temp file this request owns; deleting it on + ;; close would race the streaming body, so it is left for the OS temp + ;; sweep. A file export is the canonical per-file database and is meant + ;; to persist. + ::yres/body (io/input-stream db-path) + ::yres/headers {"content-type" "application/octet-stream" + "content-disposition" + (str "attachment; filename=" file-id + (when session? "-session") ".lbug")}}))) + +(defn- graph-console-response + [data] + {::yres/status 200 + ::yres/headers {"content-type" "text/html; charset=utf-8" + "x-robots-tag" "noindex"} + ::yres/body (-> (io/resource "app/templates/graph-console.tmpl") + (tmpl/render (assoc data :version (:full cf/version))))}) + +(defn graph-console-handler + [_cfg {:keys [::session/profile-id]}] + (let [console-context (requiring-resolve 'app.graph.debug/console-context)] + (graph-console-response (console-context profile-id)))) + +(defn graph-load-handler + [cfg {:keys [params ::session/profile-id]}] + (let [file-id (some-> (:file-id params) parse-uuid) + load-session! (requiring-resolve 'app.graph.debug/load-session!)] + (when-not file-id + (ex/raise :type :validation + :code :missing-arguments + :hint "missing file-id")) + (load-session! cfg profile-id file-id) + {::yres/status 302 + ::yres/headers {"location" "/dbg/graph"}})) + +(defn graph-unload-handler + [_cfg {:keys [::session/profile-id]}] + ((requiring-resolve 'app.graph.debug/unload-session!) profile-id) + {::yres/status 302 + ::yres/headers {"location" "/dbg/graph"}}) + +(defn graph-reload-handler + "Re-ingest the currently loaded file into the in-memory graph session." + [cfg {:keys [::session/profile-id]}] + (let [session-info (requiring-resolve 'app.graph.debug/session-info) + load-session! (requiring-resolve 'app.graph.debug/load-session!)] + (if-let [file-id (some-> (session-info profile-id) :file-id)] + (do + (load-session! cfg profile-id file-id) + {::yres/status 302 + ::yres/headers {"location" "/dbg/graph"}}) + (ex/raise :type :not-found + :code :graph-session-not-loaded + :hint "load a file graph before reloading")))) + +(defn graph-sync-status-handler + [_cfg {:keys [::session/profile-id]}] + (if-let [status ((requiring-resolve 'app.graph.debug/sync-status) profile-id)] + {::yres/status 200 + ::yres/headers {"content-type" "application/json; charset=utf-8"} + ::yres/body (t/encode-str status {:type :json-verbose})} + {::yres/status 404 + ::yres/headers {"content-type" "application/json; charset=utf-8"} + ::yres/body (t/encode-str {:error "no-session"} {:type :json-verbose})})) + +(defn graph-data-handler + "Export the in-memory session graph as plain JSON (not transit) for the + G6 graph view embedded in the console page." + [_cfg {:keys [::session/profile-id]}] + (if-let [data ((requiring-resolve 'app.graph.debug/export-graph-data!) profile-id)] + {::yres/status 200 + ::yres/headers {"content-type" "application/json; charset=utf-8"} + ::yres/body (json/encode data)} + {::yres/status 404 + ::yres/headers {"content-type" "application/json; charset=utf-8"} + ::yres/body (json/encode {:error "no-session"})})) + +(def ^:private sql:graph-files + "select t.id as team_id, t.name as team_name, + p.id as project_id, p.name as project_name, + f.id as file_id, f.name as file_name + from team as t + join team_profile_rel as tpr on (tpr.team_id = t.id) + join project as p on (p.team_id = t.id) + join file as f on (f.project_id = p.id) + where tpr.profile_id = ? + and t.deleted_at is null + and p.deleted_at is null + and f.deleted_at is null + order by t.name, p.name, f.name + limit 500") + +(defn- graph-files-tree + [rows] + (->> (group-by (juxt :team-id :team-name) rows) + (mapv (fn [[[team-id team-name] team-rows]] + {:id (str team-id) + :name team-name + :projects + (->> (group-by (juxt :project-id :project-name) team-rows) + (mapv (fn [[[project-id project-name] project-rows]] + {:id (str project-id) + :name project-name + :files (mapv (fn [{:keys [file-id file-name]}] + {:id (str file-id) :name file-name}) + project-rows)})) + (sort-by :name) + (vec))})) + (sort-by :name) + (vec))) + +(defn graph-files-handler + "List teams -> projects -> files reachable by the current profile, as + plain JSON for the graph console file tree." + [{:keys [::db/pool]} {:keys [::session/profile-id]}] + (let [rows (db/exec! pool [sql:graph-files profile-id])] + {::yres/status 200 + ::yres/headers {"content-type" "application/json; charset=utf-8"} + ::yres/body (json/encode {:teams (graph-files-tree rows)})})) + +(defn- json-request? + [request] + (some-> request + (yreq/get-header "accept") + (str/includes? "application/json"))) + +(defn graph-query-handler + [_cfg {:keys [params ::session/profile-id] :as request}] + (let [query (:query params) + query-session! (requiring-resolve 'app.graph.debug/query-session!) + console-context (requiring-resolve 'app.graph.debug/console-context)] + (try + (let [result (query-session! profile-id query)] + (if (json-request? request) + {::yres/status 200 + ::yres/headers {"content-type" "application/json; charset=utf-8"} + ::yres/body (t/encode-str {:query query + :query-result result} + {:type :json-verbose})} + (graph-console-response (console-context profile-id + :query query + :query-result result)))) + (catch Throwable e + (let [error (or (:hint (ex-data e)) (ex-message e))] + (if (json-request? request) + {::yres/status 200 + ::yres/headers {"content-type" "application/json; charset=utf-8"} + ::yres/body (t/encode-str {:query query :error error} + {:type :json-verbose})} + (graph-console-response (console-context profile-id + :query query + :error error)))))))) + (defn import-handler [{:keys [::db/pool] :as cfg} {:keys [params ::session/profile-id] :as request}] (when-not (contains? params :file) @@ -354,7 +632,10 @@ ::bfc/profile-id profile-id ::bfc/project-id project-id ::bfc/input path - ::bfc/features (cfeat/get-team-enabled-features cf/flags team))] + ::bfc/team-id (:id team) + ::bfc/features (cfeat/get-team-enabled-features cf/flags team) + ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size) + ::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))] (if (= format :binfile-v3) (bf.v3/import-files! cfg) @@ -484,6 +765,89 @@ {::yres/status 302 ::yres/headers {"location" "/dbg"}})))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; VALIDATE / REPAIR +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- validate-file + [cfg {:keys [params] :as request}] + (let [file-id (some-> params :file-id parse-uuid)] + + (when-not file-id + (ex/raise :type :validation + :code :missing-arguments)) + + (db/tx-run! (assoc cfg ::db/rollback true) + (fn [cfg] + (let [file (bfc/get-file cfg file-id) + libs (bfc/get-resolved-file-libraries cfg file-id)] + (if file + (let [errors (cfv/validate-file file libs)] + {::yres/status 200 + ::yres/headers {"content-type" "text/plain"} + ::yres/body (if (empty? errors) + "NO VALIDATION ERRORS FOUND" + (pp/pprint-str errors))}) + (ex/raise :type :not-found + :code :empty-data + :hint "empty response"))))))) + +(defn- repair-file + [cfg {:keys [params] :as request}] + (let [file-id (some-> params :file-id parse-uuid) + skip-snapshot? (contains? params :skip-snapshot) + profile-id (:app.http.session/profile-id request)] + + (when-not file-id + (ex/raise :type :validation + :code :missing-arguments)) + + (let [output (StringBuilder.) + + repair-file + (fn [file libs _] + (let [errors (cfv/validate-file file libs)] + (.append output (if (empty? errors) + "NO VALIDATION ERRORS FOUND\n" + (str "VALIDATION ERRORS FOUND:\n" + (pp/pprint-str errors) "\n"))) + (if (empty? errors) + file + (let [changes (cfr/repair-file file libs errors)] + (-> file + (update :revn inc) + (update :data cfc/process-changes changes))))))] + + (add-watch l/log-record ::repair-watcher + (fn [_ _ _ record] + (when (= "app.common.files.repair" (::l/logger record)) + (let [props (::l/props record) + hint (get props :hint "") + args (dissoc props :hint) + message (str hint " " + (when-not (empty? args) + args) + "\n")] + (.append output message))))) + (try + (db/tx-run! cfg + h/process-file! + file-id + repair-file + {::h/with-libraries? true + ::h/validate? false + ::h/profile-id profile-id + ::h/snapshot-label (when-not skip-snapshot? "repair")}) + + (.append output "\nREPAIR FINISHED") + + {::yres/status 200 + ::yres/headers {"content-type" "text/plain"} + ::yres/body (.toString output)} + + (finally + (remove-watch l/log-record ::repair-watcher)))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; OTHER SMALL VIEWS/HANDLERS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -539,7 +903,12 @@ (letfn [(handle-error [cause] (when-let [data (ex-data cause)] (when (= :validation (:type data)) - (str "Error: " (or (:hint data) (ex-message cause)) "\n"))))] + (let [hint (or (:hint data) (ex-message cause)) + explain (ex/explain data)] + (str "Error: " hint + (when (and explain (not (str/includes? hint explain))) + (str "\n" explain)) + "\n")))))] {:name ::errors :compile (fn [& _params] @@ -559,24 +928,51 @@ (assert (db/pool? (::db/pool params)) "expected a valid database pool") (assert (session/manager? (::session/manager params)) "expected a valid session manager")) +(defn- graph-action-routes + [cfg] + [["/graph-export" {:handler (partial graph-export-handler cfg)}] + ["/graph-load" {:handler (partial graph-load-handler cfg)}] + ["/graph-query" {:handler (partial graph-query-handler cfg)}] + ["/graph-unload" {:handler (partial graph-unload-handler cfg)}] + ["/graph-reload" {:handler (partial graph-reload-handler cfg)}] + ["/graph-sync-status" {:handler (partial graph-sync-status-handler cfg)}] + ["/graph-data" {:handler (partial graph-data-handler cfg)}] + ["/graph-files" {:handler (partial graph-files-handler cfg)}]]) + (defmethod ig/init-key ::routes [_ {:keys [::db/pool] :as cfg}] - [["/readyz" {:handler (partial health-handler cfg)}] - ["/dbg" {:middleware [[session/authz cfg] - [with-authorization pool]]} - ["" {:handler (partial index-handler cfg)}] - ["/health" {:handler (partial health-handler cfg)}] - ["/changelog" {:handler (partial changelog-handler cfg)}] - ["/error/:id" {:handler (partial error-handler cfg)}] - ["/error" {:handler (partial error-list-handler cfg)}] - ["/actions" {:middleware [[errors]]} - ["/set-virtual-clock" - {:handler (partial set-virtual-clock cfg)}] - ["/resend-email-verification" - {:handler (partial resend-email-notification cfg)}] - ["/handle-team-features" - {:handler (partial handle-team-features cfg)}] - ["/file-export" {:handler (partial export-handler cfg)}] - ["/file-import" {:handler (partial import-handler cfg)}] - ["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}]]]]) + ;; The graph routes are registered only under the `:graph` flag. Left + ;; unregistered they 404, and nothing ever resolves `app.graph.*`. The `/dbg` + ;; admin gate is unchanged: it covers the graph routes exactly as before. + (let [graph? (contains? cf/flags :graph) + actions (cond-> ["/actions" {:middleware [[errors]]} + ["/set-virtual-clock" + {:handler (partial set-virtual-clock cfg)}] + ["/resend-email-verification" + {:handler (partial resend-email-notification cfg)}] + ["/handle-team-features" + {:handler (partial handle-team-features cfg)}] + ["/file-export" {:handler (partial export-handler cfg)}] + ["/file-import" {:handler (partial import-handler cfg)}] + ["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}] + ["/file-validate" {:handler (partial validate-file cfg)}] + ["/file-repair" {:handler (partial repair-file cfg)}]] + graph? (into (graph-action-routes cfg))) + dbg (cond-> ["/dbg" {:middleware [[session/authz cfg] + [with-authorization pool]]} + ["" {:handler (partial index-handler cfg)}] + ["/health" {:handler (partial health-handler cfg)}] + ["/changelog" {:handler (partial changelog-handler cfg)}] + ["/error/:id" {:handler (partial error-handler cfg)}] + ["/error" {:handler (partial error-list-handler cfg)}] + actions] + graph? (conj ["/graph" {:handler (partial graph-console-handler cfg)}]))] + (when graph? + ;; With the flag on, the Ladybug native library belongs to this process, + ;; so load it here. A missing or unusable library then fails the boot + ;; instead of the first console request. + (require 'app.graph.debug 'app.graph.ingest)) + + [["/readyz" {:handler (partial health-handler cfg)}] + dbg])) diff --git a/backend/src/app/http/errors.clj b/backend/src/app/http/errors.clj index f1eaea621c..4f713a5f6b 100644 --- a/backend/src/app/http/errors.clj +++ b/backend/src/app/http/errors.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.errors "A errors handling for the http server." @@ -34,6 +34,12 @@ (assoc :request/auth-data (dissoc auth :token)) (assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown"))))) +(defn- strip-internal-fields + "Remove fields that leak internal implementation details from error + response data. Full context is preserved in server-side logs." + [data] + (dissoc data :state :path :context)) + (defmulti handle-error (fn [cause _ _] (-> cause ex-data :type))) @@ -136,6 +142,7 @@ (l/error :hint "assertion error" :cause cause) {::yres/status 500 ::yres/body (-> data + (strip-internal-fields) (assoc :type :server-error) (assoc :code :assertion))}))))) @@ -161,9 +168,9 @@ (l/error :hint "internal error" :cause cause) {::yres/status 500 ::yres/body (-> data + (strip-internal-fields) (assoc :type :server-error) - (update :code #(or % :unhandled)) - (assoc :hint (ex-message error)))}))) + (update :code #(or % :unhandled)))}))) (defmethod handle-error :default [error request parent-cause] @@ -178,6 +185,20 @@ (handle-exception (:handling edata) request error) (handle-exception error request parent-cause)))) +(defn- pgsql-state->message + "Map PostgreSQL SQLSTATE codes to safe, client-facing messages. + Returns a user-friendly string that conveys the nature of the error + without exposing table names, constraint names, or other internals." + [state] + (case state + "23505" "A conflicting entry already exists" + "23503" "The referenced item does not exist" + "23502" "A required field is missing" + "23514" "The value violates a data integrity constraint" + "57014" "The operation took too long and was cancelled" + "25P03" "The transaction was idle too long and was cancelled" + "A database error occurred")) + (defmethod handle-exception org.postgresql.util.PSQLException [error request parent-cause] (let [state (.getSQLState ^java.sql.SQLException error) @@ -190,20 +211,19 @@ {::yres/status 504 ::yres/body {:type :server-error :code :statement-timeout - :hint (ex-message error)}} + :hint (pgsql-state->message state)}} (= state "25P03") {::yres/status 504 ::yres/body {:type :server-error :code :idle-in-transaction-timeout - :hint (ex-message error)}} + :hint (pgsql-state->message state)}} :else {::yres/status 500 ::yres/body {:type :server-error - :code :unexpected - :hint (ex-message error) - :state state}})))) + :code :database-error + :hint (pgsql-state->message state)}})))) (defmethod handle-exception :default [error request parent-cause] @@ -216,17 +236,16 @@ (l/error :hint "unexpected error" :cause cause) {::yres/status 500 ::yres/body {:type :server-error - :code :unexpected - :hint (ex-message error)}}) + :code :unexpected}}) :else (binding [l/*context* (request->context request)] (l/error :hint "unhandled error" :cause cause) {::yres/status 500 ::yres/body (-> edata + (strip-internal-fields) (assoc :type :server-error) - (update :code #(or % :unhandled)) - (assoc :hint (ex-message error)))})))) + (update :code #(or % :unhandled)))})))) (defmethod handle-exception java.io.IOException [cause request _] @@ -234,9 +253,7 @@ (l/wrn :hint "io exception" :cause cause) {::yres/status 500 ::yres/body {:type :server-error - :code :io-exception - :hint (ex-message cause) - :path (:path request)}})) + :code :io-exception}})) (defmethod handle-exception java.util.concurrent.CompletionException [cause request _] diff --git a/backend/src/app/http/management.clj b/backend/src/app/http/management.clj index 507a518e5c..50ce8506ad 100644 --- a/backend/src/app/http/management.clj +++ b/backend/src/app/http/management.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.management "Internal mangement HTTP API" @@ -209,7 +209,7 @@ [:enum "customer_service" "low_quality" - "missing_feature" + "missing_features" "other" "switched_service" "too_complex" diff --git a/backend/src/app/http/middleware.clj b/backend/src/app/http/middleware.clj index fa2faa8a55..22c1cc29d9 100644 --- a/backend/src/app/http/middleware.clj +++ b/backend/src/app/http/middleware.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.middleware (:require @@ -24,7 +24,8 @@ (:import io.undertow.server.RequestTooBigException java.io.InputStream - java.io.OutputStream)) + java.io.OutputStream + java.security.MessageDigest)) (set! *warn-on-reflection* true) @@ -82,18 +83,18 @@ (instance? IllegalArgumentException cause) (ex/raise :type :validation :code :malformed-json - :hint (ex-message cause) + :hint "invalid JSON in request body" :cause cause) (instance? RequestTooBigException cause) (ex/raise :type :validation :code :request-body-too-large - :hint (ex-message cause)) + :hint "request body exceeds size limit") (instance? java.io.EOFException cause) (ex/raise :type :validation :code :malformed-json - :hint (ex-message cause) + :hint "unexpected end of request body" :cause cause) (instance? RuntimeException cause) @@ -329,6 +330,11 @@ {:name ::auth :compile (constantly wrap-auth)}) +(defn- constant-time-eq? + "Compare strings in constant time to prevent timing attacks." + [^String a ^String b] + (MessageDigest/isEqual (.getBytes a "UTF-8") (.getBytes b "UTF-8"))) + (defn- wrap-shared-key-auth [handler keys] (if (seq keys) @@ -338,7 +344,7 @@ (let [key-id (-> key-id str/lower keyword)] (if (and (string? key) (contains? keys key-id) - (= key (get keys key-id))) + (constant-time-eq? key (get keys key-id))) (-> request (assoc ::http/auth-key-id key-id) (handler)) diff --git a/backend/src/app/http/security.clj b/backend/src/app/http/security.clj index af4f875b10..5529421cb1 100644 --- a/backend/src/app/http/security.clj +++ b/backend/src/app/http/security.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.security "Additional security layer middlewares" diff --git a/backend/src/app/http/session.clj b/backend/src/app/http/session.clj index 614942c072..914dfc169c 100644 --- a/backend/src/app/http/session.clj +++ b/backend/src/app/http/session.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.session (:refer-clojure :exclude [read]) @@ -204,7 +204,7 @@ [{:keys [::manager]}] (assert (manager? manager) "expected valid session manager") (fn [request response] - (some->> (get request ::id) (delete-session manager)) + (some->> (get request ::session) :id (delete-session manager)) (clear-session-cookie response))) (defn decode-token @@ -226,6 +226,14 @@ (-> (db/exec-one! cfg [sql (:profile-id session) (:id session)]) (db/get-update-count)))) +(defn invalidate-all + "Delete all sessions for a given profile. Used when a profile is deleted + to ensure immediate access revocation across all devices." + [cfg profile-id] + (let [sql "delete from http_session_v2 where profile_id = ?"] + (-> (db/exec-one! cfg [sql profile-id]) + (db/get-update-count)))) + (def ^:private sql:clear-organization-sso-sessions (str "UPDATE http_session_v2 " "SET props = props #- ARRAY['~:sso', ?]::text[] " diff --git a/backend/src/app/http/sse.clj b/backend/src/app/http/sse.clj index 8d6290571e..0fc7c385bd 100644 --- a/backend/src/app/http/sse.clj +++ b/backend/src/app/http/sse.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.sse "SSE (server sent events) helpers" diff --git a/backend/src/app/http/websocket.clj b/backend/src/app/http/websocket.clj index 9a3972a26a..ffbaad8a2c 100644 --- a/backend/src/app/http/websocket.clj +++ b/backend/src/app/http/websocket.clj @@ -2,11 +2,12 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.websocket "A penpot notification service for file cooperative edition." (:require + [app.binfile.common :as bfc] [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.pprint :as pp] @@ -133,15 +134,15 @@ (mbus/pub! msgbus :topic topic :message msg)))) (defmethod handle-message :subscribe-team - [cfg {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [team-id] :as params}] + [{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [team-id] :as params}] (l/trace :fn "handle-message" :event "subscribe-team" :team-id team-id :conn-id id) - (teams/check-read-permissions! cfg profile-id team-id) + (teams/check-read-permissions! pool profile-id team-id) (let [prev-subs (get @state ::team-subscription) channel (sp/chan :buf (sp/dropping-buffer 64) :xf (remove #(= (:session-id %) session-id)))] (sp/pipe channel output-ch false) - (mbus/sub! (::mbus/msgbus cfg) :topic team-id :chan channel) + (mbus/sub! msgbus :topic team-id :chan channel) (let [subs {:team-id team-id :channel channel :topic team-id}] (swap! state assoc ::team-subscription subs)) @@ -149,13 +150,14 @@ ;; Close previous subscription if exists (when-let [ch (:channel prev-subs)] (sp/close! ch) - (mbus/purge! (::mbus/msgbus cfg) [ch])))) + (mbus/purge! msgbus [ch])))) (defmethod handle-message :subscribe-file - [cfg {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}] + [{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}] (l/trace :fn "handle-message" :event "subscribe-file" :file-id file-id :conn-id id) - (files/check-read-permissions! cfg profile-id file-id) + (bfc/check-file-exists pool file-id) + (files/check-read-permissions! pool profile-id file-id) (let [psub (::file-subscription @state) fch (sp/chan :buf (sp/dropping-buffer 64) :xf (remove #(= (:session-id %) session-id)))] @@ -166,7 +168,7 @@ ;; Close previous subscription if exists (when-let [ch (:channel psub)] (sp/close! ch) - (mbus/purge! (::mbus/msgbus cfg) [ch])) + (mbus/purge! msgbus [ch])) (sp/go-loop [] (when-let [{:keys [type] :as message} (sp/take! fch)] @@ -178,20 +180,20 @@ :file-id file-id :session-id session-id :profile-id profile-id}] - (mbus/pub! (::mbus/msgbus cfg) + (mbus/pub! msgbus :topic file-id :message message))) (recur))) ;; Subscribe to file topic - (mbus/sub! (::mbus/msgbus cfg) :topic file-id :chan fch) + (mbus/sub! msgbus :topic file-id :chan fch) ;; Notifify the rest of participants of the new connection. (let [message {:type :join-file :file-id file-id :session-id session-id :profile-id profile-id}] - (mbus/pub! (::mbus/msgbus cfg) :topic file-id :message message)))) + (mbus/pub! msgbus :topic file-id :message message)))) (defmethod handle-message :unsubscribe-file [{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::session-id ::profile-id]} {:keys [file-id] :as params}] diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index f68209255b..86778ab55f 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.loggers.audit "Services related to the user activity (audit log)." @@ -36,6 +36,16 @@ (def ^:private filter-auth-events #{"login-with-oidc" "login-with-password" "register-profile" "update-profile"}) +(def ^:private organization-sso-failure-reasons + #{"access-denied" + "provider-unavailable" + "invalid-configuration" + "provider-error" + "token-exchange-failed" + "user-info-failed" + "incomplete-user-info" + "unexpected-error"}) + (def ^:private safe-backend-context-keys #{:version :initiator @@ -297,6 +307,14 @@ (defn filter-telemetry-props [{:keys [source name props type] :as params}] (cond + (and (= source "backend") + (= name "organization-sso-auth-failed")) + (let [props' (into {} xf:filter-telemetry-props props) + props' (cond-> props' + (contains? organization-sso-failure-reasons (:failure-reason props)) + (assoc :failure-reason (:failure-reason props)))] + (assoc params :props props')) + (or (and (= source "frontend") (= type "identify")) (and (= source "backend") diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj index e577351e31..1c5f953d44 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/archive_task.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.loggers.audit.archive-task (:require diff --git a/backend/src/app/loggers/audit/gc_task.clj b/backend/src/app/loggers/audit/gc_task.clj index 24af10cfed..6fbd1710b4 100644 --- a/backend/src/app/loggers/audit/gc_task.clj +++ b/backend/src/app/loggers/audit/gc_task.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.loggers.audit.gc-task (:require diff --git a/backend/src/app/loggers/database.clj b/backend/src/app/loggers/database.clj index 9f9c1bebaa..cd17dcd51d 100644 --- a/backend/src/app/loggers/database.clj +++ b/backend/src/app/loggers/database.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.loggers.database "A specific logger impl that persists errors on the database." diff --git a/backend/src/app/loggers/mattermost.clj b/backend/src/app/loggers/mattermost.clj index e3089f1f03..71a61afb37 100644 --- a/backend/src/app/loggers/mattermost.clj +++ b/backend/src/app/loggers/mattermost.clj @@ -2,11 +2,12 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.loggers.mattermost "A mattermost integration for error reporting." (:require + [app.common.data :as d] [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.pprint :as pp] @@ -25,7 +26,7 @@ (defn- send-mattermost-notification! [cfg {:keys [id] :as report}] (let [type (get report :type) - text (str "#" type " | " (get report :hint) "\n" + text (str "#" type " | " (d/escape-markdown (get report :hint)) "\n" (when id (str (u/join (cf/get :public-uri) "/dbg/error/" id) " ")) @@ -38,7 +39,7 @@ "- tenant: #" (:tenant report) "\n" "- origin: #" (:origin report) "\n" (when-let [href (get report :href)] - (str "- href: `" href "`\n")) + (str "- href: `" (d/escape-markdown href) "`\n")) (when-let [version (get report :frontend-version)] (str "- frontend-version: `" version "`\n")) (when-let [version (get report :backend-version)] diff --git a/backend/src/app/loggers/webhooks.clj b/backend/src/app/loggers/webhooks.clj index a57fd6bca2..83df9825cc 100644 --- a/backend/src/app/loggers/webhooks.clj +++ b/backend/src/app/loggers/webhooks.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.loggers.webhooks "A mattermost integration for error reporting." diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 9f90d0b72f..b6f70d953c 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main (:require @@ -37,6 +37,7 @@ [app.storage.fs :as-alias sto.fs] [app.storage.gc-deleted :as-alias sto.gc-deleted] [app.storage.gc-touched :as-alias sto.gc-touched] + [app.storage.pending-gc :as-alias sto.pending-gc] [app.storage.s3 :as-alias sto.s3] [app.system :as sys] [app.util.cron] @@ -199,6 +200,10 @@ ::sto.gc-touched/handler {::db/pool (ig/ref ::db/pool)} + ::sto.pending-gc/handler + {::db/pool (ig/ref ::db/pool) + ::sto/storage (ig/ref ::sto/storage)} + ::http.client/client {} @@ -283,7 +288,9 @@ ::http.debug/routes {::db/pool (ig/ref ::db/pool) + ::rds/pool (ig/ref ::rds/pool) ::session/manager (ig/ref ::session/manager) + ::mbus/msgbus (ig/ref ::mbus/msgbus) ::sto/storage (ig/ref ::sto/storage) ::setup/props (ig/ref ::setup/props)} @@ -335,6 +342,7 @@ ::rpc/rlimit (ig/ref ::rpc/rlimit) ::setup/templates (ig/ref ::setup/templates) ::setup/props (ig/ref ::setup/props) + ::setup/shared-keys (ig/ref ::setup/shared-keys) ::email/blacklist (ig/ref ::email/blacklist) ::email/whitelist (ig/ref ::email/whitelist) @@ -385,12 +393,15 @@ :upload-session-gc (ig/ref :app.tasks.upload-session-gc/handler) :storage-gc-deleted (ig/ref ::sto.gc-deleted/handler) :storage-gc-touched (ig/ref ::sto.gc-touched/handler) + :storage-pending-gc (ig/ref ::sto.pending-gc/handler) :session-gc (ig/ref ::session.tasks/gc) :audit-log-archive (ig/ref :app.loggers.audit.archive-task/handler) :audit-log-gc (ig/ref :app.loggers.audit.gc-task/handler) :delete-object (ig/ref :app.tasks.delete-object/handler) + :demo-purge + (ig/ref :app.tasks.demo-purge/handler) :process-webhook-event (ig/ref ::webhooks/process-event-handler) :run-webhook @@ -428,6 +439,9 @@ :app.tasks.delete-object/handler {::db/pool (ig/ref ::db/pool)} + :app.tasks.demo-purge/handler + {::db/pool (ig/ref ::db/pool)} + :app.tasks.file-gc/handler {::db/pool (ig/ref ::db/pool) ::sto/storage (ig/ref ::sto/storage)} @@ -467,10 +481,11 @@ ::migrations (ig/ref :app.migrations/migrations)} ::setup/shared-keys - {::setup/props (ig/ref ::setup/props) - :nexus (cf/get :nexus-shared-key) - :admin-console (cf/get :admin-console-shared-key) - :exporter (cf/get :exporter-shared-key)} + {::setup/props (ig/ref ::setup/props) + :nexus (cf/get :nexus-shared-key) + :admin-console (cf/get :admin-console-shared-key) + :exporter (cf/get :exporter-shared-key) + :media-processor (cf/get :media-processor-shared-key)} ::setup/clock {} @@ -543,6 +558,9 @@ {:cron #penpot/cron "0 0 0 * * ?" ;; daily :task :storage-gc-touched} + {:cron #penpot/cron "0 0 0 * * ?" ;; daily + :task :storage-pending-gc} + {:cron #penpot/cron "0 0 0 * * ?" ;; daily :task :tasks-gc} diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index 30527857ad..c00ac00b24 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -2,319 +2,40 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.media - "Media & Font postprocessing." + "Media & Font postprocessing. + + This namespace is the dispatch layer only. Processing implementations + live in two separate namespaces, each owning their own defmulti: + + app.media.local — shell/ImageMagick/FontForge implementations + app.media.remote — HTTP delegation to media-processor service + + Validation and schemas live in app.media.validation (leaf namespace, + no circular dep). When adding a new :cmd type, add defmethods in + BOTH local and remote." (:require [app.common.data :as d] - [app.common.data.macros :as dm] [app.common.exceptions :as ex] - [app.common.logging :as l] - [app.common.media :as cm] - [app.common.schema :as sm] - [app.common.schema.openapi :as-alias oapi] - [app.common.time :as ct] [app.config :as cf] [app.db :as-alias db] [app.http.client :as http] + [app.media.local :as media.local] + [app.media.remote :as media.remote] [app.media.sanitize :as sanitize] + [app.media.validation :as validation] [app.storage :as-alias sto] [app.storage.tmp :as tmp] - [app.util.shell :as shell] - [buddy.core.bytes :as bb] - [buddy.core.codecs :as bc] - [clojure.string] - [clojure.xml :as xml] [cuerdas.core :as str] - [datoteka.fs :as fs] - [datoteka.io :as io]) - (:import - clojure.lang.XMLHandler - java.io.InputStream - javax.xml.parsers.SAXParserFactory - javax.xml.XMLConstants - org.apache.commons.io.IOUtils)) - -(def schema:upload - [:map {:title "Upload"} - [:filename :string] - [:size ::sm/int] - [:path ::fs/path] - [:mtype {:optional true} :string] - [:headers {:optional true} - [:map-of :string :string]]]) - -(def ^:private schema:input - [:map {:title "Input"} - [:path ::fs/path] - [:mtype {:optional true} ::sm/text]]) - -(def check-input - (sm/check-fn schema:input)) - -(defn validate-media-type! - ([upload] (validate-media-type! upload cm/image-types)) - ([upload allowed] - (when-not (contains? allowed (:mtype upload)) - (ex/raise :type :validation - :code :media-type-not-allowed - :hint "Seems like you are uploading an invalid media object")) - - upload)) - -(defn validate-media-size! - [upload] - (let [max-size (cf/get :media-max-file-size)] - (when (> (:size upload) max-size) - (ex/raise :type :restriction - :code :media-max-file-size-reached - :hint (str/ffmt "the uploaded file size % is greater than the maximum %" - (:size upload) - max-size))) - upload)) - -(defn validate-font-size! - "Validates that the font file `upload` does not exceed the configured - `:font-max-file-size` limit. Accepts the same map shape as - `validate-media-size!` — requires a `:size` key in bytes." - [upload] - (let [max-size (cf/get :font-max-file-size)] - (when (> (:size upload) max-size) - (ex/raise :type :restriction - :code :font-max-file-size-reached - :hint (str/ffmt "the uploaded font size % is greater than the maximum %" - (:size upload) - max-size))) - upload)) - -(defmulti process (fn [_system params] (:cmd params))) - -(defmethod process :default - [_system {:keys [cmd] :as params}] - (ex/raise :type :internal - :code :not-implemented - :hint (str/fmt "No impl found for process cmd: %s" cmd))) + [datoteka.io :as io])) (defn run [system params] - (process system params)) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; SVG PARSING -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- secure-parser-factory - [^InputStream input ^XMLHandler handler] - (.. (doto (SAXParserFactory/newInstance) - (.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true) - (.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true)) - (newSAXParser) - (parse input handler))) - -(defn- strip-doctype - [data] - (cond-> data - (str/includes? data "<!DOCTYPE") - (str/replace #"<\!DOCTYPE[^>]*>" ""))) - -(defn- parse-svg - [text] - (let [text (strip-doctype text)] - (dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")] - (xml/parse istream secure-parser-factory)))) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; IMAGE THUMBNAILS -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(def ^:private schema:thumbnail-params - [:map {:title "ThumbnailParams"} - [:input schema:input] - [:format [:enum :jpeg :webp :png]] - [:quality [:int {:min 1 :max 100}]] - [:width :int] - [:height :int]]) - -(def ^:private check-thumbnail-params - (sm/check-fn schema:thumbnail-params)) - -;; Related info on how thumbnails generation -;; http://www.imagemagick.org/Usage/thumbnails/ - -(def ^:private imagemagick-default-env - "Default environment variables for ImageMagick resource limits. - These are the soft ceiling — policy.xml is the hard ceiling." - {"MAGICK_THREAD_LIMIT" "2" - "MAGICK_MEMORY_LIMIT" "256MiB" - "MAGICK_MAP_LIMIT" "512MiB" - "MAGICK_AREA_LIMIT" "128MP" - "MAGICK_DISK_LIMIT" "1GiB" - "MAGICK_TIME_LIMIT" "30"}) - -(defn- get-imagemagick-env - "Returns environment variables for ImageMagick commands. - Reads individual PENPOT_IMAGEMAGICK_* config values, falling back to defaults." - [] - (let [thread (cf/get :imagemagick-thread-limit) - memory (cf/get :imagemagick-memory-limit) - map-l (cf/get :imagemagick-map-limit) - area (cf/get :imagemagick-area-limit) - disk (cf/get :imagemagick-disk-limit) - time (cf/get :imagemagick-time-limit) - width (cf/get :imagemagick-width-limit) - height (cf/get :imagemagick-height-limit)] - (cond-> imagemagick-default-env - thread (assoc "MAGICK_THREAD_LIMIT" thread) - memory (assoc "MAGICK_MEMORY_LIMIT" memory) - map-l (assoc "MAGICK_MAP_LIMIT" map-l) - area (assoc "MAGICK_AREA_LIMIT" area) - disk (assoc "MAGICK_DISK_LIMIT" disk) - time (assoc "MAGICK_TIME_LIMIT" time) - width (assoc "MAGICK_WIDTH_LIMIT" width) - height (assoc "MAGICK_HEIGHT_LIMIT" height)))) - -(defn- exec-magick! - "Execute an ImageMagick command with resource limits. - `args` is a vector of string arguments to pass to `magick`." - [system args] - (let [cmd (into ["magick"] args) - result (shell/exec! system - :cmd cmd - :env (get-imagemagick-env) - :timeout 60)] - (when (not= 0 (:exit result)) - (ex/raise :type :validation - :code :invalid-image - :hint (str "ImageMagick command failed: " (:err result)) - :cmd cmd - :exit (:exit result))) - result)) - -(defn- generic-process - [system {:keys [input format convert-args] :as params}] - (let [{:keys [path mtype]} input - format (or format (cm/mtype->format mtype)) - ext (cm/format->extension format) - tmp (tmp/tempfile :prefix "penpot.media." :suffix ext) - args (into [(str path)] (conj (vec convert-args) (str tmp)))] - (exec-magick! system args) - (assoc params - :format format - :mtype (cm/format->mtype format) - :size (fs/size tmp) - :data tmp))) - -(defmethod process :generic-thumbnail - [system params] - (let [{:keys [quality width height] :as params} - (check-thumbnail-params params)] - (generic-process system - (assoc params - :convert-args ["-auto-orient" "-strip" - "-thumbnail" (str width "x" height ">") - "-quality" (str quality)])))) - -(defmethod process :profile-thumbnail - [system params] - (let [{:keys [quality width height] :as params} - (check-thumbnail-params params)] - (generic-process system - (assoc params - :convert-args ["-auto-orient" "-strip" - "-thumbnail" (str width "x" height "^") - "-gravity" "center" - "-extent" (str width "x" height) - "-quality" (str quality)])))) - -(defn get-basic-info-from-svg - [{:keys [tag attrs] :as data}] - (when (not= tag :svg) - (ex/raise :type :validation - :code :unable-to-parse-svg - :hint "uploaded svg has invalid content")) - (reduce (fn [default f] - (if-let [res (f attrs)] - (reduced res) - default)) - {:width 100 :height 100} - [(fn parse-width-and-height - [{:keys [width height]}] - (when (and (string? width) - (string? height)) - (let [width (d/parse-double width) - height (d/parse-double height)] - (when (and width height) - {:width (int width) - :height (int height)})))) - (fn parse-viewbox - [{:keys [viewBox]}] - (let [[x y width height] (->> (str/split viewBox #"\s+" 4) - (map d/parse-double))] - (when (and x y width height) - {:width (int width) - :height (int height)})))])) - -(defn- get-dimensions-with-orientation [system ^String path] - ;; Image magick doesn't give info about exif rotation so we use the identify command - ;; If we are processing an animated gif we use the first frame with -scene 0 - (let [dim-result (exec-magick! system ["identify" "-format" "%w %h\n" path]) - orient-result (exec-magick! system ["identify" "-format" "%[EXIF:Orientation]\n" path])] - (when (= 0 (:exit dim-result)) - (let [[w h] (-> (:out dim-result) - str/trim - (clojure.string/split #"\s+") - (->> (mapv #(Integer/parseInt %)))) - orientation-exit (:exit orient-result) - orientation (-> orient-result :out str/trim)] - (if (= 0 orientation-exit) - (case orientation - ("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees - {:width w :height h}) ; Normal or unknown orientation - {:width w :height h}))))) ; If orientation can't be read, use dimensions as-is - -(defmethod process :info - [system {:keys [input] :as params}] - (let [{:keys [path mtype] :as input} (check-input input)] - (if (= mtype "image/svg+xml") - (let [info (some-> path slurp parse-svg get-basic-info-from-svg)] - (when-not info - (ex/raise :type :validation - :code :invalid-svg-file - :hint "uploaded svg does not provides dimensions")) - (merge input info {:ts (ct/now) :size (fs/size path)})) - - (let [path-str (str path) - identify-res (exec-magick! system ["identify" "-format" "image/%[magick]\n" path-str]) - ;; identify prints one line per frame (animated GIFs, etc.); we take the first one - mtype' (if (zero? (:exit identify-res)) - (-> identify-res - :out - str/trim - (str/split #"\s+" 2) - first - str/lower) - (ex/raise :type :validation - :code :invalid-image - :hint "invalid image")) - {:keys [width height]} - (or (get-dimensions-with-orientation system path-str) - (do - (l/warn "Failed to read image dimensions with orientation" {:path path}) - (ex/raise :type :validation - :code :invalid-image - :hint "invalid image")))] - (when (and (string? mtype) - (not= (str/lower mtype) mtype')) - (ex/raise :type :validation - :code :media-type-mismatch - :hint (str "Seems like you are uploading a file whose content does not match the extension." - "Expected: " mtype ". Got: " mtype'))) - (assoc input - :width width - :height height - :size (fs/size path) - :ts (ct/now)))))) + (if (contains? cf/flags :remote-media-processing) + (media.remote/process system params) + (media.local/process system params))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; IMAGE HELPERS @@ -338,8 +59,8 @@ :hint "seems like the url points to resource with unknown size")) (-> {:size size :mtype mtype} - (validate-media-type!) - (validate-media-size!))))] + (validation/validate-media-type!) + (validation/validate-media-size!))))] (let [{:keys [body] :as response} (try @@ -367,188 +88,24 @@ (ex/raise :type :validation :code :unable-to-download-image :hint (str/ffmt "unable to download image from '%': I/O error" uri) - :cause cause))) + :cause cause)))] - {:keys [size mtype]} (parse-and-validate response) - path (tmp/tempfile :prefix "penpot.media.download.") - written (io/write* path body :size size)] + (if body + (with-open [body body] + (let [{:keys [size mtype]} (parse-and-validate response) + path (tmp/tempfile :prefix "penpot.media.download.") + written (io/write* path body :size size)] - (when (not= written size) - (ex/raise :type :internal - :code :mismatch-write-size - :hint "unexpected state: unable to write to file")) + (when (not= written size) + (ex/raise :type :internal + :code :mismatch-write-size + :hint "unexpected state: unable to write to file")) - ;; Sanitize: strip trailing data after image EOF markers - (let [new-size (sanitize/truncate-after-eof path mtype)] - {:path path - :mtype mtype - :size new-size})))) + ;; Sanitize: strip trailing data after image EOF markers + (let [new-size (sanitize/truncate-after-eof path mtype)] + {:path path + :mtype mtype + :size new-size}))) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; FONTS -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- get-font-prlimit - "Returns resource limits for font processing tools, read from config." - [] - {:mem (cf/get :font-process-mem) - :cpu (cf/get :font-process-cpu)}) - -(defn- get-font-timeout - "Returns the wall-clock timeout for font processing, read from config." - [] - (cf/get :font-process-timeout)) - -(defn- exec-font! - "Execute a font processing command with resource limits. - `args` is a vector of string arguments." - [system args] - (shell/exec! system - :cmd args - :prlimit (get-font-prlimit) - :timeout (get-font-timeout))) - -(defmethod process :generate-fonts - [system {:keys [input] :as params}] - (letfn [(ttf->otf [data] - (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "") - foutput (fs/path (str finput ".otf"))] - (try - (io/write* finput data) - (let [res (exec-font! system ["fontforge" "-lang=ff" "-c" - (str/fmt "Open('%s'); Generate('%s')" - (str finput) - (str foutput))])] - (when (zero? (:exit res)) - foutput)) - (finally - (fs/delete finput))))) - - (otf->ttf [data] - (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "") - foutput (fs/path (str finput ".ttf"))] - (try - (io/write* finput data) - (let [res (exec-font! system ["fontforge" "-lang=ff" "-c" - (str/fmt "Open('%s'); Generate('%s')" - (str finput) - (str foutput))])] - (when (zero? (:exit res)) - foutput)) - (finally - (fs/delete finput))))) - - (ttf-or-otf->woff [data] - (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "") - foutput (fs/path (str finput ".woff"))] - (try - (io/write* finput data) - (let [res (exec-font! system ["sfnt2woff" (str finput)])] - (when (zero? (:exit res)) - foutput)) - (finally - (fs/delete finput))))) - - (woff->sfnt [data] - (let [finput (tmp/tempfile :prefix "penpot" :suffix "")] - (try - (io/write* finput data) - (let [res (shell/exec! system - :cmd ["woff2sfnt" (str finput)] - :out-enc :bytes - :prlimit (get-font-prlimit) - :timeout (get-font-timeout))] - (when (zero? (:exit res)) - (:out res))) - (finally - (fs/delete finput))))) - - (woff2->sfnt [data] - ;; woff2_decompress outputs to same directory with .ttf extension - (let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2") - foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))] - (try - (io/write* finput data) - (let [res (exec-font! system ["woff2_decompress" (str finput)])] - (if (zero? (:exit res)) - foutput - (do - (when (fs/exists? foutput) - (fs/delete foutput)) - nil))) - (finally - (fs/delete finput))))) - - ;; Documented here: - ;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory - (get-sfnt-type [data] - (let [buff (bb/slice data 0 4) - type (bc/bytes->hex buff)] - (case type - "4f54544f" :otf - "00010000" :ttf - (ex/raise :type :internal - :code :unexpected-data - :hint "unexpected font data")))) - - (gen-if-nil [val factory] - (if (nil? val) - (factory) - val))] - - (let [current (into #{} (keys input))] - (cond - (contains? current "font/ttf") - (let [data (get input "font/ttf")] - (-> input - (update "font/otf" gen-if-nil #(ttf->otf data)) - (update "font/woff" gen-if-nil #(ttf-or-otf->woff data)))) - - (contains? current "font/otf") - (let [data (get input "font/otf")] - (-> input - (update "font/woff" gen-if-nil #(ttf-or-otf->woff data)) - (assoc "font/ttf" (otf->ttf data)))) - - (contains? current "font/woff") - (let [data (get input "font/woff") - sfnt (woff->sfnt data)] - (when-not sfnt - (ex/raise :type :validation - :code :invalid-woff-file - :hint "invalid woff file")) - (let [stype (get-sfnt-type sfnt)] - (cond-> input - true - (-> (assoc "font/woff" data)) - - (= stype :otf) - (-> (assoc "font/otf" sfnt) - (assoc "font/ttf" (otf->ttf sfnt))) - - (= stype :ttf) - (-> (assoc "font/otf" (ttf->otf sfnt)) - (assoc "font/ttf" sfnt))))) - - (contains? current "font/woff2") - (let [data (get input "font/woff2") - foutput (woff2->sfnt data)] - (when-not foutput - (ex/raise :type :validation - :code :invalid-woff2-file - :hint "invalid woff2 file")) - (try - (let [sfnt (io/read* foutput) - type (get-sfnt-type sfnt)] - (cond-> input - (= type :otf) - (-> (assoc "font/otf" sfnt) - (assoc "font/ttf" (otf->ttf sfnt)) - (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt))) - - (= type :ttf) - (-> (assoc "font/ttf" sfnt) - (assoc "font/otf" (ttf->otf sfnt)) - (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt))))) - (finally - (fs/delete foutput)))))))) + ;; No body - validation will raise appropriate error + (parse-and-validate response))))) diff --git a/backend/src/app/media/local.clj b/backend/src/app/media/local.clj new file mode 100644 index 0000000000..f3811e83aa --- /dev/null +++ b/backend/src/app/media/local.clj @@ -0,0 +1,366 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.media.local + "Local media processing via ImageMagick and FontForge shell commands." + (:require + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.common.media :as cm] + [app.common.schema :as sm] + [app.common.time :as ct] + [app.config :as cf] + [app.media.svg :as svg] + [app.media.validation :as validation] + [app.storage.tmp :as tmp] + [app.util.shell :as shell] + [buddy.core.bytes :as bb] + [buddy.core.codecs :as bc] + [clojure.string] + [cuerdas.core :as str] + [datoteka.fs :as fs] + [datoteka.io :as io])) + +(defmulti process (fn [_system params] (:cmd params))) + +(defmethod process :default + [_system {:keys [cmd] :as params}] + (ex/raise :type :internal + :code :not-implemented + :hint (str/fmt "No impl found for local process cmd: %s" cmd))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; IMAGE THUMBNAILS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private schema:thumbnail-params + [:map {:title "ThumbnailParams"} + [:input validation/schema:input] + [:format [:enum :jpeg :webp :png]] + [:quality [:int {:min 1 :max 100}]] + [:width :int] + [:height :int]]) + +(def ^:private check-thumbnail-params + (sm/check-fn schema:thumbnail-params)) + +;; Related info on how thumbnails generation +;; http://www.imagemagick.org/Usage/thumbnails/ + +(def ^:private imagemagick-default-env + "Default environment variables for ImageMagick resource limits. + These are the soft ceiling — policy.xml is the hard ceiling." + {"MAGICK_THREAD_LIMIT" "2" + "MAGICK_MEMORY_LIMIT" "256MiB" + "MAGICK_MAP_LIMIT" "512MiB" + "MAGICK_AREA_LIMIT" "128MP" + "MAGICK_DISK_LIMIT" "1GiB" + "MAGICK_TIME_LIMIT" "30"}) + +(defn- get-imagemagick-env + "Returns environment variables for ImageMagick commands. + Reads individual PENPOT_IMAGEMAGICK_* config values, falling back to defaults." + [] + (let [thread (cf/get :imagemagick-thread-limit) + memory (cf/get :imagemagick-memory-limit) + map-l (cf/get :imagemagick-map-limit) + area (cf/get :imagemagick-area-limit) + disk (cf/get :imagemagick-disk-limit) + time (cf/get :imagemagick-time-limit) + width (cf/get :imagemagick-width-limit) + height (cf/get :imagemagick-height-limit)] + (cond-> imagemagick-default-env + thread (assoc "MAGICK_THREAD_LIMIT" thread) + memory (assoc "MAGICK_MEMORY_LIMIT" memory) + map-l (assoc "MAGICK_MAP_LIMIT" map-l) + area (assoc "MAGICK_AREA_LIMIT" area) + disk (assoc "MAGICK_DISK_LIMIT" disk) + time (assoc "MAGICK_TIME_LIMIT" time) + width (assoc "MAGICK_WIDTH_LIMIT" width) + height (assoc "MAGICK_HEIGHT_LIMIT" height)))) + +(defn- exec-magick! + "Execute an ImageMagick command with resource limits. + `args` is a vector of string arguments to pass to `magick`." + [system args] + (let [cmd (into ["magick"] args) + result (shell/exec! system + :cmd cmd + :env (get-imagemagick-env) + :timeout 60)] + (when (not= 0 (:exit result)) + (ex/raise :type :validation + :code :invalid-image + :hint (str "ImageMagick command failed: " (:err result)) + :cmd cmd + :exit (:exit result))) + result)) + +(defn- generic-process + [system {:keys [input format convert-args] :as params}] + (let [{:keys [path mtype]} input + format (or format (cm/mtype->format mtype)) + ext (cm/format->extension format) + tmp (tmp/tempfile :prefix "penpot.media." :suffix ext) + args (into [(str path)] (conj (vec convert-args) (str tmp)))] + (exec-magick! system args) + (assoc params + :format format + :mtype (cm/format->mtype format) + :size (fs/size tmp) + :data tmp))) + +(defmethod process :generic-thumbnail + [system params] + (let [{:keys [quality width height] :as params} + (check-thumbnail-params params)] + (generic-process system + (assoc params + :convert-args ["-auto-orient" "-strip" + "-thumbnail" (str width "x" height ">") + "-quality" (str quality)])))) + +(defmethod process :profile-thumbnail + [system params] + (let [{:keys [quality width height] :as params} + (check-thumbnail-params params)] + (generic-process system + (assoc params + :convert-args ["-auto-orient" "-strip" + "-thumbnail" (str width "x" height "^") + "-gravity" "center" + "-extent" (str width "x" height) + "-quality" (str quality)])))) + +(defn- get-dimensions-with-orientation [system ^String path] + ;; Image magick doesn't give info about exif rotation so we use the identify command + ;; If we are processing an animated gif we use the first frame with -scene 0 + (let [dim-result (exec-magick! system ["identify" "-format" "%w %h\n" path]) + orient-result (exec-magick! system ["identify" "-format" "%[EXIF:Orientation]\n" path])] + (when (= 0 (:exit dim-result)) + (let [[w h] (-> (:out dim-result) + str/trim + (clojure.string/split #"\s+") + (->> (mapv #(Integer/parseInt %)))) + orientation-exit (:exit orient-result) + orientation (-> orient-result :out str/trim)] + (if (= 0 orientation-exit) + (case orientation + ("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees + {:width w :height h}) ; Normal or unknown orientation + {:width w :height h}))))) ; If orientation can't be read, use dimensions as-is + +(defmethod process :info + [system {:keys [input] :as params}] + (let [{:keys [path mtype] :as input} (validation/check-input input)] + (if (= mtype "image/svg+xml") + (let [info (some-> path slurp svg/parse-svg svg/get-basic-info-from-svg)] + (when-not info + (ex/raise :type :validation + :code :invalid-svg-file + :hint "uploaded svg does not provides dimensions")) + (merge input info {:ts (ct/now) :size (fs/size path)})) + + (let [path-str (str path) + identify-res (exec-magick! system ["identify" "-format" "image/%[magick]\n" path-str]) + ;; identify prints one line per frame (animated GIFs, etc.); we take the first one + mtype' (if (zero? (:exit identify-res)) + (-> identify-res + :out + str/trim + (str/split #"\s+" 2) + first + str/lower) + (ex/raise :type :validation + :code :invalid-image + :hint "invalid image")) + {:keys [width height]} + (or (get-dimensions-with-orientation system path-str) + (do + (l/warn "Failed to read image dimensions with orientation" {:path path}) + (ex/raise :type :validation + :code :invalid-image + :hint "invalid image")))] + (when (and (string? mtype) + (not= (str/lower mtype) mtype')) + (ex/raise :type :validation + :code :media-type-mismatch + :hint (str "Seems like you are uploading a file whose content does not match the extension." + "Expected: " mtype ". Got: " mtype'))) + (assoc input + :width width + :height height + :size (fs/size path) + :ts (ct/now)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; FONTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- get-font-prlimit + "Returns resource limits for font processing tools, read from config." + [] + {:mem (cf/get :font-process-mem) + :cpu (cf/get :font-process-cpu)}) + +(defn- get-font-timeout + "Returns the wall-clock timeout for font processing, read from config." + [] + (cf/get :font-process-timeout)) + +(defn- exec-font! + "Execute a font processing command with resource limits. + `args` is a vector of string arguments." + [system args] + (shell/exec! system + :cmd args + :prlimit (get-font-prlimit) + :timeout (get-font-timeout))) + +(defmethod process :generate-fonts + [system {:keys [input] :as params}] + (letfn [(ttf->otf [data] + (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "") + foutput (fs/path (str finput ".otf"))] + (try + (io/write* finput data) + (let [res (exec-font! system ["fontforge" "-lang=ff" "-c" + (str/fmt "Open('%s'); Generate('%s')" + (str finput) + (str foutput))])] + (when (zero? (:exit res)) + foutput)) + (finally + (fs/delete finput))))) + + (otf->ttf [data] + (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "") + foutput (fs/path (str finput ".ttf"))] + (try + (io/write* finput data) + (let [res (exec-font! system ["fontforge" "-lang=ff" "-c" + (str/fmt "Open('%s'); Generate('%s')" + (str finput) + (str foutput))])] + (when (zero? (:exit res)) + foutput)) + (finally + (fs/delete finput))))) + + (ttf-or-otf->woff [data] + (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "") + foutput (fs/path (str finput ".woff"))] + (try + (io/write* finput data) + (let [res (exec-font! system ["sfnt2woff" (str finput)])] + (when (zero? (:exit res)) + foutput)) + (finally + (fs/delete finput))))) + + (woff->sfnt [data] + (let [finput (tmp/tempfile :prefix "penpot" :suffix "")] + (try + (io/write* finput data) + (let [res (shell/exec! system + :cmd ["woff2sfnt" (str finput)] + :out-enc :bytes + :prlimit (get-font-prlimit) + :timeout (get-font-timeout))] + (when (zero? (:exit res)) + (:out res))) + (finally + (fs/delete finput))))) + + (woff2->sfnt [data] + ;; woff2_decompress outputs to same directory with .ttf extension + (let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2") + foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))] + (try + (io/write* finput data) + (let [res (exec-font! system ["woff2_decompress" (str finput)])] + (if (zero? (:exit res)) + foutput + (do + (when (fs/exists? foutput) + (fs/delete foutput)) + nil))) + (finally + (fs/delete finput))))) + + ;; Documented here: + ;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory + (get-sfnt-type [data] + (let [buff (bb/slice data 0 4) + type (bc/bytes->hex buff)] + (case type + "4f54544f" :otf + "00010000" :ttf + (ex/raise :type :internal + :code :unexpected-data + :hint "unexpected font data")))) + + (gen-if-nil [val factory] + (if (nil? val) + (factory) + val))] + + (let [current (into #{} (keys input))] + (cond + (contains? current "font/ttf") + (let [data (get input "font/ttf")] + (-> input + (update "font/otf" gen-if-nil #(ttf->otf data)) + (update "font/woff" gen-if-nil #(ttf-or-otf->woff data)))) + + (contains? current "font/otf") + (let [data (get input "font/otf")] + (-> input + (update "font/woff" gen-if-nil #(ttf-or-otf->woff data)) + (assoc "font/ttf" (otf->ttf data)))) + + (contains? current "font/woff") + (let [data (get input "font/woff") + sfnt (woff->sfnt data)] + (when-not sfnt + (ex/raise :type :validation + :code :invalid-woff-file + :hint "invalid woff file")) + (let [stype (get-sfnt-type sfnt)] + (cond-> input + true + (-> (assoc "font/woff" data)) + + (= stype :otf) + (-> (assoc "font/otf" sfnt) + (assoc "font/ttf" (otf->ttf sfnt))) + + (= stype :ttf) + (-> (assoc "font/otf" (ttf->otf sfnt)) + (assoc "font/ttf" sfnt))))) + + (contains? current "font/woff2") + (let [data (get input "font/woff2") + foutput (woff2->sfnt data)] + (when-not foutput + (ex/raise :type :validation + :code :invalid-woff2-file + :hint "invalid woff2 file")) + (try + (let [sfnt (io/read* foutput) + type (get-sfnt-type sfnt)] + (cond-> input + (= type :otf) + (-> (assoc "font/otf" sfnt) + (assoc "font/ttf" (otf->ttf sfnt)) + (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt))) + + (= type :ttf) + (-> (assoc "font/ttf" sfnt) + (assoc "font/otf" (ttf->otf sfnt)) + (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt))))) + (finally + (fs/delete foutput)))))))) diff --git a/backend/src/app/media/remote.clj b/backend/src/app/media/remote.clj new file mode 100644 index 0000000000..1004035703 --- /dev/null +++ b/backend/src/app/media/remote.clj @@ -0,0 +1,264 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.media.remote + "Remote media processing via the media-processor HTTP service." + (:require + [app.common.exceptions :as ex] + [app.common.media :as cm] + [app.common.time :as ct] + [app.common.uri :as uri] + [app.config :as cf] + [app.http.client :as http] + [app.media.svg :as svg] + [app.media.validation :as validation] + [app.setup :as-alias setup] + [app.storage.tmp :as tmp] + [app.util.json :as json] + [cuerdas.core :as str] + [datoteka.fs :as fs] + [datoteka.io :as io]) + (:import + java.io.ByteArrayInputStream + java.io.InputStream + java.io.SequenceInputStream + java.net.ConnectException + java.net.http.HttpTimeoutException + java.util.Collections)) + +(defn- service-base-url + "Returns the base URL of the media-processor service." + [] + (or (cf/get :media-processing-service-uri) + (ex/raise :type :internal + :code :media-processor-not-configured + :hint "PENPOT_MEDIA_PROCESSING_SERVICE_URI is not configured"))) + +(defn- service-timeout + "Returns the HTTP timeout (ms) for media-processor requests." + [] + (or (cf/get :media-processing-service-timeout) + 120000)) + +(defn- get-shared-key + "Returns the shared key for authenticating with the media-processor." + [system] + (-> system ::setup/shared-keys :media-processor)) + +(defn- parse-json-response + "Parse a JSON response body." + [body] + (json/read! body)) + +(defn- translate-error + "Translate a media-processor error response into a Penpot exception." + [status body] + (let [code (or (:code body) "media-processor-error") + hint (or (:hint body) "media-processor request failed")] + (case status + 400 {:type :validation :code (keyword code) :hint hint} + 403 {:type :authorization :code :forbidden :hint hint} + 413 {:type :restriction :code (keyword code) :hint hint} + 504 {:type :internal :code :media-processor-timeout :hint hint} + {:type :internal :code (keyword code) :hint hint}))) + +(defn service-request + "Make an HTTP request to the media-processor service." + [system {:keys [method uri body headers timeout]}] + (let [client (::http/client system) + timeout (or timeout (service-timeout))] + (try + (let [resp (http/req client + {:method method + :uri uri + :body body + :headers headers} + {:response-type :input-stream + :skip-ssrf-check? true + :timeout timeout}) + status (:status resp)] + (when (not (<= 200 status 299)) + (let [body (:body resp)] + (try + (let [parsed (try (parse-json-response body) (catch Exception _ nil)) + err (translate-error status parsed)] + (ex/raise :type (:type err) :code (:code err) :hint (:hint err))) + (finally + (.close body))))) + resp) + (catch ConnectException _cause + (ex/raise :type :internal + :code :media-processor-unavailable + :hint "Cannot connect to media-processor service")) + (catch HttpTimeoutException _cause + (ex/raise :type :internal + :code :media-processor-timeout + :hint "media-processor service request timed out"))))) + +(defn- multipart-boundary + [] + (str "----PenpotBoundary" (System/currentTimeMillis))) + +(defn- build-multipart-stream + "Build a streaming multipart/form-data body with a single file field. + Returns an InputStream that lazily reads from the file on demand." + [^String boundary mtype ^InputStream file-stream] + (let [header (.getBytes (str "--" boundary "\r\n" + "Content-Disposition: form-data; name=\"file\"; filename=\"file\"\r\n" + "Content-Type: " mtype "\r\n" + "\r\n") + "UTF-8") + footer (.getBytes (str "\r\n--" boundary "--\r\n") + "UTF-8") + parts (Collections/enumeration + [(ByteArrayInputStream. header) + file-stream + (ByteArrayInputStream. footer)])] + (SequenceInputStream. parts))) + +(defn- service-multipart-request + "Send a multipart request to the media-processor service. + Accepts a file from disk via :path. The file stream is closed + after the HTTP request completes (success or failure)." + [system {:keys [endpoint path mtype query timeout]}] + (let [shared-key (get-shared-key system) + boundary (multipart-boundary) + ctype (or mtype "application/octet-stream") + base-url (service-base-url) + request-uri (cond-> (uri/join base-url endpoint) + (seq query) + (str "?" (uri/map->query-string query)))] + (with-open [file-stream (io/input-stream path)] + (let [body (build-multipart-stream boundary ctype file-stream)] + (service-request system + {:method :post + :uri request-uri + :body body + :headers {"Content-Type" (str "multipart/form-data; boundary=" boundary) + "x-shared-key" shared-key} + :timeout timeout}))))) + +(def ^:private known-font-types + "Priority-ordered list of font mime-types the system knows how to convert. + Order matters: when a font upload contains multiple variants, the first + match becomes the conversion source (ttf preferred for best coverage)." + ["font/ttf" "font/otf" "font/woff" "font/woff2"]) + +(defn- font-convert + "Convert a font to the given target mime-type via the media-processor service. + Accepts source font data as a filesystem Path. Returns a tempfile Path." + [system source-mtype target-mtype data] + (let [resp (service-multipart-request system {:endpoint "api/font/convert" + :path data + :mtype source-mtype + :query {:target-type target-mtype} + :timeout 180000}) + ext (cm/mtype->extension target-mtype) + tmp (tmp/tempfile :prefix "penpot.font." :suffix ext) + body (:body resp)] + (try + (io/write* tmp body) + (finally + (.close body))) + tmp)) + +(defn- font-missing-variants + "Return the set of target mime-types that should be generated for the given + source mime-type (excluding font/woff2, which is never generated)." + [source-mtype] + (case source-mtype + "font/ttf" #{"font/otf" "font/woff"} + "font/otf" #{"font/ttf" "font/woff"} + "font/woff" #{"font/ttf" "font/otf"} + "font/woff2" #{"font/ttf" "font/otf" "font/woff"})) + +(defmulti process (fn [_system params] (:cmd params))) + +(defmethod process :info + [system {:keys [input]}] + (let [{:keys [path mtype]} (validation/check-input input)] + (if (= mtype "image/svg+xml") + ;; SVG: parse locally (Sharp doesn't support SVG) + (let [info (some-> path slurp svg/parse-svg svg/get-basic-info-from-svg)] + (when-not info + (ex/raise :type :validation + :code :invalid-svg-file + :hint "uploaded svg does not provide dimensions")) + (merge input info {:ts (ct/now) :size (fs/size path)})) + ;; Raster: delegate to media-processor + (let [resp (service-multipart-request system {:endpoint "api/image/info" + :path path + :mtype mtype}) + body (:body resp)] + (try + (let [info (parse-json-response body) + detected-mtype (:mtype info)] + (when (and (string? mtype) + (string? detected-mtype) + (not= (str/lower mtype) (str/lower detected-mtype))) + (ex/raise :type :validation + :code :media-type-mismatch + :hint (str "File content does not match the declared type. " + "Expected: " mtype ". Got: " detected-mtype))) + (assoc input + :width (:width info) + :height (:height info) + :size (fs/size path) + :ts (ct/now))) + (finally + (.close body))))))) + +(defn- thumbnail-request + "Shared implementation for generic-thumbnail and profile-thumbnail." + [system params mode] + (let [{:keys [input format quality width height]} params + {:keys [path mtype]} (validation/check-input input) + fmt (name (or format (cm/mtype->format mtype) :jpeg)) + resp (service-multipart-request system {:endpoint "api/image/thumbnail" + :path path + :mtype mtype + :query {:width width + :height height + :quality quality + :format fmt + :mode mode}}) + out-format (or format (cm/mtype->format mtype) :jpeg) + ext (cm/format->extension out-format) + tmp (tmp/tempfile :prefix "penpot.media." :suffix ext) + body (:body resp)] + (try + (io/write* tmp body) + (finally + (.close body))) + (assoc params + :format out-format + :mtype (cm/format->mtype out-format) + :size (fs/size tmp) + :data tmp))) + +(defmethod process :generic-thumbnail + [system params] + (thumbnail-request system params "fit")) + +(defmethod process :profile-thumbnail + [system params] + (thumbnail-request system params "crop")) + +(defmethod process :generate-fonts + [system {:keys [input]}] + (let [source-mtype (or (some #(when (contains? input %) %) known-font-types) + (ex/raise :type :validation + :code :invalid-font + :hint "No recognized font variant in input")) + data (get input source-mtype) + present (set (keys input)) + targets (remove present (font-missing-variants source-mtype))] + (reduce (fn [acc target-mtype] + (assoc acc target-mtype + (font-convert system source-mtype target-mtype data))) + input + targets))) + diff --git a/backend/src/app/media/sanitize.clj b/backend/src/app/media/sanitize.clj index f67414501c..6f0d9465d5 100644 --- a/backend/src/app/media/sanitize.clj +++ b/backend/src/app/media/sanitize.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.media.sanitize "Image EOF truncation helpers — strips trailing data after image EOF diff --git a/backend/src/app/media/svg.clj b/backend/src/app/media/svg.clj new file mode 100644 index 0000000000..287322d460 --- /dev/null +++ b/backend/src/app/media/svg.clj @@ -0,0 +1,130 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.media.svg + "SVG parsing, sanitization, and info extraction. + Centralizes all SVG-related security concerns." + (:require + [app.common.data :as d] + [app.common.data.macros :as dm] + [app.common.exceptions :as ex] + [app.common.logging :as l] + [clojure.xml :as xml] + [cuerdas.core :as str]) + (:import + clojure.lang.XMLHandler + java.io.InputStream + javax.xml.parsers.SAXParserFactory + javax.xml.XMLConstants + org.apache.commons.io.IOUtils)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SVG PARSING +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- secure-parser-factory + [^InputStream input ^XMLHandler handler] + (.. (doto (SAXParserFactory/newInstance) + (.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true) + (.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true)) + (newSAXParser) + (parse input handler))) + +(defn- strip-doctype + [data] + (cond-> data + (str/includes? data "<!DOCTYPE") + (str/replace #"<\!DOCTYPE[^>]*>" ""))) + +(defn parse-svg + [text] + (let [text (strip-doctype text)] + (dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")] + (xml/parse istream secure-parser-factory)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SVG SANITIZATION +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private dangerous-attrs-pattern #"(?i)^on\w+$") +(def ^:private javascript-href-pattern #"(?i)^javascript:") + +(defn- sanitize-svg-element + "Recursively sanitize an SVG element by removing dangerous tags and attributes." + [{:keys [tag attrs content] :as element}] + (when (and (map? element) tag) + (let [dangerous-tags #{:script :foreignObject :set :animate :animateTransform :animateColor :animateMotion}] + (when-not (contains? dangerous-tags tag) + (let [clean-attrs (->> attrs + (remove (fn [[k v]] + (or (re-matches dangerous-attrs-pattern (name k)) + (and (#{:href :xlink:href} k) + (string? v) + (re-find javascript-href-pattern (str/trim v)))))) + (into {})) + clean-content (when content + (->> content + (filter #(or (string? %) (map? %))) + (map (fn [child] + (if (map? child) + (sanitize-svg-element child) + child))) + (filter some?) + vec))] + (cond-> {:tag tag :attrs clean-attrs} + (seq clean-content) (assoc :content clean-content))))))) + +(defn sanitize-svg + "Sanitize SVG content by removing dangerous elements and attributes. + Removes <script> tags, <foreignObject> elements, event handlers (on*), + and javascript: URLs from href attributes." + [svg-text] + (try + (let [parsed (parse-svg svg-text) + sanitized (sanitize-svg-element parsed)] + (if sanitized + (with-out-str (xml/emit sanitized)) + (ex/raise :type :validation + :code :invalid-svg-file + :hint "SVG sanitization produced no output"))) + (catch Exception e + (l/warn :hint "SVG sanitization failed, rejecting upload" :cause e) + (ex/raise :type :validation + :code :invalid-svg-file + :hint "SVG parsing failed during sanitization" + :cause e)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SVG INFO EXTRACTION +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn get-basic-info-from-svg + [{:keys [tag attrs] :as data}] + (when (not= tag :svg) + (ex/raise :type :validation + :code :unable-to-parse-svg + :hint "uploaded svg has invalid content")) + (reduce (fn [default f] + (if-let [res (f attrs)] + (reduced res) + default)) + {:width 100 :height 100} + [(fn parse-width-and-height + [{:keys [width height]}] + (when (and (string? width) + (string? height)) + (let [width (d/parse-double width) + height (d/parse-double height)] + (when (and width height) + {:width (int width) + :height (int height)})))) + (fn parse-viewbox + [{:keys [viewBox]}] + (let [[x y width height] (->> (str/split viewBox #"\s+" 4) + (map d/parse-double))] + (when (and x y width height) + {:width (int width) + :height (int height)})))])) diff --git a/backend/src/app/media/validation.clj b/backend/src/app/media/validation.clj new file mode 100644 index 0000000000..5da86fb99f --- /dev/null +++ b/backend/src/app/media/validation.clj @@ -0,0 +1,68 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.media.validation + "Schemas and validation functions for media uploads. + Leaf namespace — depends on app.common.* and app.config only." + (:require + [app.common.exceptions :as ex] + [app.common.media :as cm] + [app.common.schema :as sm] + [app.config :as cf] + [cuerdas.core :as str] + [datoteka.fs :as fs])) + +(def schema:upload + [:map {:title "Upload"} + [:filename :string] + [:size ::sm/int] + [:path ::fs/path] + [:mtype {:optional true} :string] + [:headers {:optional true} + [:map-of :string :string]]]) + +(def schema:input + [:map {:title "Input"} + [:path ::fs/path] + [:mtype {:optional true} ::sm/text]]) + +(def check-input + (sm/check-fn schema:input)) + +(defn validate-media-type! + ([upload] (validate-media-type! upload cm/image-types)) + ([upload allowed] + (when-not (contains? allowed (:mtype upload)) + (ex/raise :type :validation + :code :media-type-not-allowed + :hint "Seems like you are uploading an invalid media object")) + + upload)) + +(defn validate-media-size! + [upload] + (let [max-size (cf/get :media-max-file-size)] + (when (> (:size upload) max-size) + (ex/raise :type :restriction + :code :media-max-file-size-reached + :hint (str/ffmt "the uploaded file size % is greater than the maximum %" + (:size upload) + max-size))) + upload)) + +(defn validate-font-size! + "Validates that the font file `upload` does not exceed the configured + `:font-max-file-size` limit. Accepts the same map shape as + `validate-media-size!` — requires a `:size` key in bytes." + [upload] + (let [max-size (cf/get :font-max-file-size)] + (when (> (:size upload) max-size) + (ex/raise :type :restriction + :code :font-max-file-size-reached + :hint (str/ffmt "the uploaded font size % is greater than the maximum %" + (:size upload) + max-size))) + upload)) diff --git a/backend/src/app/metrics.clj b/backend/src/app/metrics.clj index fa1b8bab0b..63145eb5b5 100644 --- a/backend/src/app/metrics.clj +++ b/backend/src/app/metrics.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.metrics (:refer-clojure :exclude [run!]) diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 2edb8614d5..ff65057bff 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.migrations (:require @@ -499,7 +499,10 @@ :fn (mg/resource "app/migrations/sql/0152-improve-uuid-defaults-and-drop-extension.sql")} {:name "0152-rename-version-and-add-indexes-to-server-error-report" - :fn (mg/resource "app/migrations/sql/0152-rename-version-and-add-indexes-to-server-error-report.sql")}]) + :fn (mg/resource "app/migrations/sql/0152-rename-version-and-add-indexes-to-server-error-report.sql")} + + {:name "0153-add-storage-object-status-and-deletion-attempts" + :fn (mg/resource "app/migrations/sql/0153-add-storage-object-status-and-deletion-attempts.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/clj/migration_0023.clj b/backend/src/app/migrations/clj/migration_0023.clj index 2ee4151de4..d41d55cab8 100644 --- a/backend/src/app/migrations/clj/migration_0023.clj +++ b/backend/src/app/migrations/clj/migration_0023.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.migrations.clj.migration-0023 (:require diff --git a/backend/src/app/migrations/clj/migration_0145.clj b/backend/src/app/migrations/clj/migration_0145.clj index d8a0f0fc21..7d7e46fafb 100644 --- a/backend/src/app/migrations/clj/migration_0145.clj +++ b/backend/src/app/migrations/clj/migration_0145.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.migrations.clj.migration-0145 "Migrate plugins references on profiles" diff --git a/backend/src/app/migrations/media_refs.clj b/backend/src/app/migrations/media_refs.clj index eb624ace88..7be652794c 100644 --- a/backend/src/app/migrations/media_refs.clj +++ b/backend/src/app/migrations/media_refs.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.migrations.media-refs "A media refs migration fixer script" diff --git a/backend/src/app/migrations/sql/0153-add-storage-object-status-and-deletion-attempts.sql b/backend/src/app/migrations/sql/0153-add-storage-object-status-and-deletion-attempts.sql new file mode 100644 index 0000000000..68efad792a --- /dev/null +++ b/backend/src/app/migrations/sql/0153-add-storage-object-status-and-deletion-attempts.sql @@ -0,0 +1,24 @@ +--- Add a status column and a deletion attempts counter to storage_object. + +--- The status column tracks the write-ahead lifecycle of newly created +--- objects. A row is inserted as 'pending' before its blob is written to +--- the underlying storage subsystem and promoted to 'valid' once the +--- write succeeds. Rows in 'pending' state are excluded from the normal +--- lifecycle (deduplication, gc, reads) until they become valid; a +--- periodic task (:storage-pending-gc) reclaims pending rows that were +--- never promoted (e.g. after a crash). + +ALTER TABLE storage_object + ADD COLUMN status text NOT NULL DEFAULT 'valid' + CHECK (status IN ('valid', 'pending')); + +CREATE INDEX storage_object__status_created_at__idx + ON storage_object (status, created_at) + WHERE status = 'pending'; + +--- The deletion_attempts counter tracks how many times the gc_deleted +--- task has attempted to physically delete the blob. After max attempts +--- the row is removed and the blob is left as an orphan. + +ALTER TABLE storage_object + ADD COLUMN deletion_attempts bigint NOT NULL DEFAULT 0; diff --git a/backend/src/app/msgbus.clj b/backend/src/app/msgbus.clj index 85828d3f23..f6a05ff586 100644 --- a/backend/src/app/msgbus.clj +++ b/backend/src/app/msgbus.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.msgbus "The msgbus abstraction implemented using redis as underlying backend." diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 7f4b470ab3..6cc5dcde91 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.nitrate "Module that make calls to the external nitrate aplication" @@ -167,6 +167,9 @@ [:id ::sm/uuid] [:name ::sm/text] [:owner-id ::sm/uuid] + [:logo-id {:optional true} [:maybe ::sm/uuid]] + [:avatar-bg-url {:optional true} [:maybe ::sm/uri]] + [:sso-active {:optional true} [:maybe ::sm/boolean]] [:teams [:vector [:map @@ -242,7 +245,7 @@ [:enum "customer_service" "low_quality" - "missing_feature" + "missing_features" "other" "switched_service" "too_complex" @@ -259,6 +262,14 @@ (generate-nitrate-uri "api/teams/" team-id) cto/schema:team-with-organization params)) +(defn- get-teams-organizations-api + [cfg {:keys [team-ids] :as params}] + (let [params (assoc params :request-params {:team-ids team-ids})] + (request-to-nitrate cfg :post + (generate-nitrate-uri "api/teams/organizations") + [:vector cto/schema:team-with-organization] + params))) + (defn- get-organization-membership-api [cfg {:keys [profile-id organization-id] :as params}] (request-to-nitrate cfg :get @@ -489,6 +500,7 @@ [_ cfg] (when (contains? cf/flags :admin-console) {:get-team-organization (partial get-team-organization-api cfg) + :get-teams-organizations (partial get-teams-organizations-api cfg) :set-team-organization (partial set-team-organization-api cfg) :get-organization-membership (partial get-organization-membership-api cfg) :get-organization-membership-by-team (partial get-organization-membership-by-team-api cfg) @@ -596,22 +608,25 @@ :cause cause) profile))))) +(defn- apply-organization-info-to-team + [team team-with-organization] + (let [organization (:organization team-with-organization)] + (if (some? organization) + (-> (cto/apply-organization team (assoc organization :custom-photo + (when-let [logo-id (:logo-id organization)] + (generate-public-uri "assets/by-id/" logo-id)))) + (assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization))))) + team))) + (defn add-organization-info-to-team "Enriches a team map with organization information from Nitrate. - Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields. Returns the original team unchanged if the request fails or organization data is nil. Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable." [cfg team params] (try - (let [params (assoc (or params {}) :team-id (:id team)) - team-with-organization (call cfg :get-team-organization params) - organization (:organization team-with-organization)] - (if (some? organization) - (-> (cto/apply-organization team (assoc organization :custom-photo - (when-let [logo-id (:logo-id organization)] - (generate-public-uri "assets/by-id/" logo-id)))) - (assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization))))) - team)) + (let [params (assoc (or params {}) :team-id (:id team)) + team-with-organization (call cfg :get-team-organization params)] + (apply-organization-info-to-team team team-with-organization)) (catch Throwable cause (if (= :nitrate-unavailable (-> cause ex-data :type)) (throw cause) @@ -621,6 +636,23 @@ :cause cause) team))))) +(defn add-organization-info-to-teams + "Enriches teams with organization information using one batched Nitrate request. + Teams absent from the Nitrate response are returned unchanged. + Rejects the request when Nitrate does not return a valid batch response." + [cfg teams params] + (let [request-params (assoc (or params {}) :team-ids (mapv :id teams)) + teams-with-organization (call cfg :get-teams-organizations request-params)] + (when (nil? teams-with-organization) + (ex/raise :type :nitrate-unavailable + :hint "nitrate did not return a valid teams organization response")) + (let [organizations-by-team (into {} (map (juxt :id identity)) teams-with-organization)] + (mapv (fn [{:keys [id] :as team}] + (if-let [team-with-organization (get organizations-by-team id)] + (apply-organization-info-to-team team team-with-organization) + team)) + teams)))) + (defn set-team-organization "Associates a team with an organization in Nitrate. Requires organization-id and is-default in params. @@ -637,3 +669,17 @@ :context {:team-id (:id team) :organization-id (:organization-id params)})) team)) + +(defn assert-membership + "Verifies that the user is a member of the organization. + Raises an exception if the organization doesn't exist or the user is not a member." + [cfg profile-id organization-id] + (let [membership (call cfg :get-organization-membership {:profile-id profile-id + :organization-id organization-id})] + (when-not (:organization-id membership) + (ex/raise :type :validation + :code :organization-does-not-exist)) + + (when-not (:is-member membership) + (ex/raise :type :validation + :code :user-doesnt-belong-organization)))) diff --git a/backend/src/app/redis.clj b/backend/src/app/redis.clj index be9e331dda..7a71d4ba66 100644 --- a/backend/src/app/redis.clj +++ b/backend/src/app/redis.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.redis "The msgbus abstraction implemented using redis as underlying backend." @@ -29,6 +29,7 @@ io.lettuce.core.api.sync.RedisScriptingCommands io.lettuce.core.codec.RedisCodec io.lettuce.core.codec.StringCodec + io.lettuce.core.KeyScanCursor io.lettuce.core.KeyValue io.lettuce.core.pubsub.api.sync.RedisPubSubCommands io.lettuce.core.pubsub.RedisPubSubListener @@ -40,6 +41,8 @@ io.lettuce.core.RedisURI io.lettuce.core.resource.ClientResources io.lettuce.core.resource.DefaultClientResources + io.lettuce.core.ScanArgs + io.lettuce.core.ScanCursor io.lettuce.core.ScriptOutputType io.lettuce.core.SetArgs io.netty.channel.nio.NioEventLoopGroup @@ -71,6 +74,8 @@ (-blpop [_ timeout keys]) (-eval [_ script]) (-get [_ key]) + (-scan [_ cursor pattern limit]) + (-hget [_ key field]) (-set [_ key val args]) (-del [_ key-or-keys]) (-ping [_])) @@ -205,6 +210,20 @@ (assert (string? key) "key expected to be string") (.get cmd ^String key)) + (-scan [_ cursor pattern limit] + (let [args (-> (ScanArgs.) + (.match ^String pattern) + (.limit (long limit))) + result (.scan cmd + ^ScanCursor (ScanCursor/of ^String cursor) + ^ScanArgs args)] + (MapEntry/create + (.getCursor ^KeyScanCursor result) + (vec (.getKeys ^KeyScanCursor result))))) + + (-hget [_ key field] + (.hget cmd ^String key ^String field)) + (-set [_ key val args] (.set cmd ^String key @@ -345,6 +364,26 @@ (l/err :hint "timeout on get redis key" :key key :cause cause) nil))) +(defn scan + [conn cursor pattern limit] + (assert (string? cursor) "cursor must be string instance") + (assert (string? pattern) "pattern must be string instance") + (try + (-scan conn cursor pattern limit) + (catch RedisCommandTimeoutException cause + (l/err :hint "timeout on scan" :pattern pattern :cause cause) + nil))) + +(defn hget + [conn key field] + (assert (string? key) "key must be string instance") + (assert (string? field) "field must be string instance") + (try + (-hget conn key field) + (catch RedisCommandTimeoutException cause + (l/err :hint "timeout on hget" :key key :cause cause) + nil))) + (defn set ([conn key val] (set conn key val nil)) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index c7e33312ca..6c7b1a480a 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc (:require @@ -261,23 +261,28 @@ (defn- wrap-nitrate-sso "Enforce Nitrate organization SSO authentication for RPC handlers. - Resolves the organization/team context from request params using priority order: - 1. Explicit :organization-id param - 2. Explicit :team-id param - 3. Explicit :project-id param -> lookup project.team_id - 4. Explicit :file-id param -> lookup file's team via join - 5. :id param dispatched by ::rpc/id-type metadata (:team, :project, or :file) + Resolves the organization/team context from request params: + 1. Explicit :organization-id param identifies the organization directly + 2. The team comes from the first available of: explicit :team-id, explicit + :project-id -> lookup project.team_id, explicit :file-id -> lookup file's + team via join, or the :id param dispatched by ::rpc/id-type metadata + (:team, :project, or :file) Once the context is resolved, checks if the user is authorized within that organization's - SSO session using nitrate/sso-session-authorized?. Authorized results are cached - by [profile-id cache-ref] for 15 minutes to avoid repeated lookups. + SSO session using nitrate/sso-session-authorized?, against the organization when it is + known and against the team otherwise. The team is resolved either way, so the raised + error can carry it. Authorized results are cached by [profile-id cache-ref] for 15 + minutes to avoid repeated lookups. Only activates when: - Nitrate flag is enabled - Endpoint requires authentication (::auth true by default) - Endpoint is not marked with ::nitrate/organization-sso false - Raises :nitrate-sso-required error if user is not authorized in the organization." + Raises :nitrate-sso-required error if user is not authorized in the organization. + The error carries the resolved :organization-id and :team-id so the client can + restart the SSO flow (via :check-nitrate-sso) instead of reporting a plain + permission failure." [_ f mdata] (if (and (contains? cf/flags :admin-console) (::auth mdata true) ;; only for endpoints that needs auth @@ -302,17 +307,22 @@ cached (cache/get organization-sso-auth-cache cache-key) result (if (some? cached) cached - (let [team-id (when-not organization-id - (or team-id - (when project-id - (:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]}))) + ;; The team is resolved even when the organization is + ;; already known: the client needs it to restart the + ;; SSO flow without sending non-members through the + ;; organization's identity provider. + (let [team-id (or team-id + (when project-id + (:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]}))) + (when file-id (:id (teams/get-team-for-file cfg file-id)))) request (-> (meta params) (get ::http/request)) {:keys [authorized sso]} (if organization-id (nitrate/sso-session-authorized? cfg organization-id nil request) (nitrate/sso-session-authorized? cfg nil team-id request)) entry {:authorized authorized - :organization-id (:organization-id sso)}] + :organization-id (or (:organization-id sso) organization-id) + :team-id team-id}] (when authorized (cache/get organization-sso-auth-cache cache-key (constantly entry))) entry))] @@ -320,6 +330,8 @@ (f cfg params) (ex/raise :type :authentication :code :nitrate-sso-required + :organization-id (:organization-id result) + :team-id (:team-id result) :hint "organization SSO authentication required"))) (f cfg params)))) f)) @@ -388,6 +400,7 @@ 'app.rpc.commands.management 'app.rpc.commands.media 'app.rpc.commands.nitrate + 'app.rpc.commands.plugins 'app.rpc.commands.profile 'app.rpc.commands.projects 'app.rpc.commands.search diff --git a/backend/src/app/rpc/climit.clj b/backend/src/app/rpc/climit.clj index 60d14af09b..daff05550b 100644 --- a/backend/src/app/rpc/climit.clj +++ b/backend/src/app/rpc/climit.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.climit "Concurrencly limiter for RPC." diff --git a/backend/src/app/rpc/commands/access_token.clj b/backend/src/app/rpc/commands/access_token.clj index 0aa20ba3c1..09aa9189c2 100644 --- a/backend/src/app/rpc/commands/access_token.clj +++ b/backend/src/app/rpc/commands/access_token.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.access-token (:require diff --git a/backend/src/app/rpc/commands/audit.clj b/backend/src/app/rpc/commands/audit.clj index 0f4bf1c320..fceb258cf8 100644 --- a/backend/src/app/rpc/commands/audit.clj +++ b/backend/src/app/rpc/commands/audit.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.audit "Audit Log related RPC methods" @@ -183,6 +183,7 @@ (sv/defmethod ::get-enabled-flags {::audit/skip true + ::rpc/auth false ::doc/skip true ::doc/added "1.20"} [_cfg _params] diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index 5444273862..b134d2a0c9 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -2,12 +2,13 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.auth (:require [app.auth :as auth] [app.auth.oidc :as oidc] + [app.auth.passwords :as passwords] [app.common.data :as d] [app.common.exceptions :as ex] [app.common.features :as cfeat] @@ -182,6 +183,7 @@ (db/update! conn :profile {:password pwd :is-active true} {:id profile-id}) nil))] + (passwords/validate-password password) (->> (validate-token token) (update-password conn)) @@ -240,6 +242,9 @@ :code :email-as-password :hint "you can't use your email as password")) + ;; Validate password strength against common password dictionary + (passwords/validate-password (:password params)) + (when (eml/has-bounce-reports? cfg (:email params)) (ex/raise :type :restriction :code :email-has-permanent-bounces @@ -258,7 +263,8 @@ (validate-register-attempt! cfg params) (let [email (profile/clean-email email) - profile (profile/get-profile-by-email pool email)] + profile (profile/get-profile-by-email pool email) + fullname (d/normalize-string fullname)] ;; SECURITY: refuse to issue a prepared-register token when an active ;; profile already exists for this email. @@ -359,6 +365,9 @@ is-active (:is-active params false) theme (:theme params nil) email (str/lower email) + fullname (d/normalize-string (:fullname params)) + locale (d/normalize-string locale) + theme (some-> theme d/normalize-string not-empty) photo-id (some->> (or (:oidc/picture props) (:google/picture props) @@ -367,7 +376,7 @@ (import-profile-picture cfg)) params {:id id - :fullname (:fullname params) + :fullname fullname :email email :auth-backend backend :lang locale diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index 79b0bf7cf9..a1537dc501 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.binfile (:refer-clojure :exclude [assert]) @@ -19,8 +19,9 @@ [app.http.sse :as sse] [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as-alias webhooks] - [app.media :as media] + [app.media.validation :as media.v] [app.rpc :as-alias rpc] + [app.rpc.climit :as-alias climit] [app.rpc.commands.files :as files] [app.rpc.commands.media :as media-cmd] [app.rpc.commands.projects :as projects] @@ -41,17 +42,24 @@ schema:export-binfile [:map {:title "export-binfile"} [:file-id ::sm/uuid] - [:include-libraries ::sm/boolean] - [:embed-assets ::sm/boolean]]) + [:type {:optional true} [::sm/one-of #{:include-libraries :merge-libraries :detach-libraries :link-later}]] + [:include-libraries {:optional true} ::sm/boolean] + [:embed-assets {:optional true} ::sm/boolean]]) (defn- export-binfile - [{:keys [::sto/storage] :as cfg} {:keys [file-id include-libraries embed-assets]}] - (let [output (tmp/tempfile*)] + [{:keys [::sto/storage] :as cfg} {:keys [type file-id include-libraries embed-assets]}] + (let [output (tmp/tempfile*) + ;; Convert legacy boolean flags to unified export-type + export-type (cond + (some? type) type + (true? include-libraries) :include-libraries + (true? embed-assets) :merge-libraries + :else :detach-libraries)] + (try (-> cfg (assoc ::bfc/ids #{file-id}) - (assoc ::bfc/embed-assets embed-assets) - (assoc ::bfc/include-libraries include-libraries) + (assoc ::bfc/export-type export-type) (bf.v3/export-files! output)) (let [data (sto/content output) @@ -59,10 +67,11 @@ {::sto/content data ::sto/touched-at (ct/in-future {:minutes 60}) :content-type "application/zip" - :bucket "tempfile"})] + :bucket sto/tempfile-bucket})] (-> (cf/get :public-uri) - (u/join "/assets/by-id/") + (u/ensure-path-slash) + (u/join "assets/by-id/") (u/join (str (:id object))))) (finally @@ -71,7 +80,8 @@ (sv/defmethod ::export-binfile "Export a penpot file in a binary format." {::doc/added "1.15" - ::doc/changes [["2.12" "Remove version parameter, only one version is supported"]] + ::doc/changes [["2.12" "Remove version parameter, only one version is supported"] + ["2.19" "Deprecated `include-libraries` and `embed-assets` params"]] ::webhooks/event? true ::sm/params schema:export-binfile} [cfg {:keys [::rpc/profile-id file-id] :as params}] @@ -92,7 +102,10 @@ (assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team)) (assoc ::bfc/project-id project-id) (assoc ::bfc/profile-id profile-id) - (assoc ::bfc/name name)) + (assoc ::bfc/team-id (:id team)) + (assoc ::bfc/name name) + (assoc ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size)) + (assoc ::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))) input-path (:path file) owned? (some? upload-id) @@ -104,7 +117,11 @@ (try (case (int version) 1 (bf.v1/import-files! cfg) - 3 (bf.v3/import-files! cfg)) + 3 (bf.v3/import-files! cfg) + (throw (ex-info (str "Unsupported binfile version: " version) + {:type :validation + :code :unsupported-version + :version version}))) (finally (when owned? (fs/delete input-path))))] @@ -122,58 +139,56 @@ [:name [:or [:string {:max 250}] [:map-of ::sm/uuid [:string {:max 250}]]]] [:project-id ::sm/uuid] - [:file-id {:optional true} ::sm/uuid] - [:version {:optional true} ::sm/int] - [:file {:optional true} media/schema:upload] + [:version {:optional true} [:enum 1 3]] + [:file {:optional true} media.v/schema:upload] [:upload-id {:optional true} ::sm/uuid]] [:fn {:error/message "one of :file or :upload-id is required"} (fn [{:keys [file upload-id]}] (or (some? file) (some? upload-id)))]]) (sv/defmethod ::import-binfile - "Import a penpot file in a binary format. If `file-id` is provided, - an in-place import will be performed instead of creating a new file. - - The in-place imports are only supported for binfile-v3 and when a - .penpot file only contains one penpot file. + "Import a penpot file in a binary format. The file content may be provided either as a multipart `file` upload or as an `upload-id` referencing a completed chunked-upload session, which allows importing files larger than the multipart size limit. " {::doc/added "1.15" - ::doc/changes ["1.20" "Add file-id param for in-place import" - "1.20" "Set default version to 3" - "2.15" "Add upload-id param for chunked upload support"] + ::doc/changes [["1.20" "Set default version to 3"] + ["2.15" "Add upload-id param for chunked upload support"]] ::webhooks/event? true ::sse/stream? true - ::sm/params schema:import-binfile} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version file-id upload-id] :as params}] + ::sm/params schema:import-binfile + ::climit/id [[:import-binfile/by-profile ::rpc/profile-id] + [:import-binfile/global]]} + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version upload-id] :as params}] (projects/check-edition-permissions! pool profile-id project-id) - (let [version (or version 3) + (let [params (if (some? upload-id) + (let [file (db/tx-run! cfg media-cmd/assemble-chunks profile-id upload-id)] + (assoc params :file file)) + params) + + version (or version + (case (bfc/parse-file-format (-> params :file :path)) + :binfile-v1 1 + :binfile-v3 3)) + params (-> params (assoc :profile-id profile-id) (assoc :version version)) - cfg (cond-> cfg - (uuid? file-id) - (assoc ::bfc/file-id file-id)) - - params - (if (some? upload-id) - (let [file (db/tx-run! cfg media-cmd/assemble-chunks upload-id)] - (assoc params :file file)) - params) - manifest (case (int version) 1 nil - 3 (bf.v3/get-manifest (-> params :file :path)))] + 3 (bf.v3/get-manifest (-> params :file :path)) + (throw (ex-info (str "Unsupported binfile version: " version) + {:type :validation + :code :unsupported-version + :version version})))] (with-meta (sse/response (partial import-binfile cfg params)) {::audit/props {:file nil - :file-id file-id :generated-by (:generated-by manifest) :referer (:referer manifest)}}))) diff --git a/backend/src/app/rpc/commands/comments.clj b/backend/src/app/rpc/commands/comments.clj index 6a926d1e98..037e4494b2 100644 --- a/backend/src/app/rpc/commands/comments.clj +++ b/backend/src/app/rpc/commands/comments.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.comments (:require @@ -231,8 +231,11 @@ ::sm/params schema:get-comment-threads} [cfg {:keys [::rpc/profile-id file-id share-id] :as params}] (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] - (files/check-comment-permissions! cfg profile-id file-id share-id) - (get-comment-threads conn profile-id file-id)))) + (let [perms (files/check-comment-permissions! cfg profile-id file-id share-id) + threads (get-comment-threads conn profile-id file-id)] + (if (= :share-link (:type perms)) + (filterv #(contains? (:pages perms) (:page-id %)) threads) + threads))))) (defn- get-comment-threads-sql [where] @@ -329,9 +332,15 @@ ::sm/params schema:get-comment-thread} [cfg {:keys [::rpc/profile-id file-id id share-id] :as params}] (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] - (files/check-comment-permissions! cfg profile-id file-id share-id) - (some-> (db/exec-one! conn [sql:get-comment-thread profile-id file-id id]) - (decode-row))))) + (let [perms (files/check-comment-permissions! cfg profile-id file-id share-id) + thread (some-> (db/exec-one! conn [sql:get-comment-thread profile-id file-id id]) + (decode-row))] + (when (and thread (= :share-link (:type perms))) + (when-not (contains? (:pages perms) (:page-id thread)) + (ex/raise :type :not-found + :code :object-not-found + :hint "not found"))) + thread)))) ;; --- COMMAND: Retrieve Comments @@ -348,8 +357,13 @@ ::sm/params schema:get-comments} [cfg {:keys [::rpc/profile-id thread-id share-id]}] (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] - (let [{:keys [file-id]} (get-comment-thread conn thread-id)] - (files/check-comment-permissions! cfg profile-id file-id share-id) + (let [{:keys [file-id page-id]} (get-comment-thread conn thread-id) + perms (files/check-comment-permissions! cfg profile-id file-id share-id)] + (when (and (= :share-link (:type perms)) + (not (contains? (:pages perms) page-id))) + (ex/raise :type :not-found + :code :object-not-found + :hint "not found")) (get-comments conn thread-id))))) (def sql:get-comments diff --git a/backend/src/app/rpc/commands/demo.clj b/backend/src/app/rpc/commands/demo.clj index 13b7a2f374..c21ae33838 100644 --- a/backend/src/app/rpc/commands/demo.clj +++ b/backend/src/app/rpc/commands/demo.clj @@ -2,14 +2,15 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.demo "A demo specific mutations." (:require - [app.auth :refer [derive-password]] + [app.auth :refer [derive-password-weak]] [app.common.exceptions :as ex] - [app.common.time :as ct] + [app.common.schema :as sm] + [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] [app.loggers.audit :as audit] @@ -17,25 +18,33 @@ [app.rpc.commands.auth :as auth] [app.rpc.doc :as-alias doc] [app.util.services :as sv] + [app.worker :as wrk] [buddy.core.codecs :as bc] [buddy.core.nonce :as bn])) +(def ^:private + schema:create-demo-profile + [:map + [:skip-onboarding {:optional true} ::sm/boolean]]) + (sv/defmethod ::create-demo-profile "A command that is responsible of creating a demo purpose profile. It only works if the `demo-users` flag is enabled in the configuration." {::rpc/auth false ::doc/added "1.15" - ::doc/changes ["1.15" "This method is migrated from mutations to commands."]} - [cfg _] + ::doc/changes [["1.15" "This method is migrated from mutations to commands."] + ["2.18" "Add optional `skip-onboarding` param. When true, the profile is created with `onboarding-viewed` and `release-notes-viewed` (current version) set, skipping the onboarding flow."]] + ::sm/params schema:create-demo-profile} + [cfg {:keys [skip-onboarding]}] (when-not (contains? cf/flags :demo-users) (ex/raise :type :validation :code :demo-users-not-allowed :hint "Demo users are disabled by config.")) - (let [sem (System/currentTimeMillis) - email (str "demo-" sem ".demo@example.com") + (let [sem (uuid/next) + email (str "demo-" sem "@demo.example.com") fullname (str "Demo User " sem) password (-> (bn/random-bytes 16) @@ -46,13 +55,23 @@ :fullname fullname :is-active true :is-demo true - :deleted-at (ct/in-future (cf/get-deletion-delay)) - :password (derive-password password) - :props {}} + :password (derive-password-weak password) + :props (cond-> {} + skip-onboarding (assoc :onboarding-viewed true + ;; Redundant today: auth/create-profile + ;; overwrites this with the current + ;; version, kept so the skip does not + ;; depend on that default. + :release-notes-viewed (:main cf/version)))} profile (db/tx-run! cfg (fn [cfg] (->> (auth/create-profile cfg params) (auth/create-profile-rels cfg))))] + + (wrk/submit! (-> cfg + (assoc ::wrk/task :demo-purge) + (assoc ::wrk/delay (cf/get-deletion-delay)) + (assoc ::wrk/params {:profile-id (:id profile)}))) + (with-meta {:email email :password password} {::audit/profile-id (:id profile)}))) - diff --git a/backend/src/app/rpc/commands/error_reports.clj b/backend/src/app/rpc/commands/error_reports.clj index dfb6e6ba49..2ed30879f3 100644 --- a/backend/src/app/rpc/commands/error_reports.clj +++ b/backend/src/app/rpc/commands/error_reports.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.error-reports "RPC methods for listing and fetching server error reports. diff --git a/backend/src/app/rpc/commands/feedback.clj b/backend/src/app/rpc/commands/feedback.clj index 565f41d30e..346697abbc 100644 --- a/backend/src/app/rpc/commands/feedback.clj +++ b/backend/src/app/rpc/commands/feedback.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.feedback "A general purpose feedback module." @@ -14,22 +14,25 @@ [app.db :as db] [app.email :as eml] [app.rpc :as-alias rpc] + [app.rpc.climit :as-alias climit] [app.rpc.commands.profile :as profile] [app.rpc.doc :as-alias doc] [app.util.services :as sv])) (declare ^:private send-user-feedback!) -(def ^:private schema:send-user-feedback +(def schema:send-user-feedback [:map {:title "send-user-feedback"} [:subject [:string {:max 500}]] [:content [:string {:max 2500}]] [:type {:optional true} :string] [:error-href {:optional true} [:string {:max 2500}]] - [:error-report {:optional true} :string]]) + [:error-report {:optional true} [:string {:max 1048576}]]]) (sv/defmethod ::send-user-feedback - {::doc/added "1.18" + {::climit/id [[:send-user-feedback/by-profile ::rpc/profile-id] + [:send-user-feedback/global]] + ::doc/added "1.18" ::sm/params schema:send-user-feedback} [{:keys [::db/pool]} {:keys [::rpc/profile-id] :as params}] (when-not (contains? cf/flags :user-feedback) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index e10c85a7bd..9b518ca9d6 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.files (:require @@ -95,18 +95,23 @@ (def check-read-permissions! (perms/make-check-fn has-read-permissions?)) -;; A user has comment permissions if she has read permissions, or -;; explicit comment permissions through the share-id +;; A user has comment permissions if: +;; - For :membership type: they have read permissions OR explicit comment permissions +;; - For :share-link type: they must have explicit comment permissions (who-comment=all) +;; This prevents share-link holders with who-comment=team from bypassing the restriction (defn check-comment-permissions! [cfg profile-id file-id share-id] - (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id) - can-read (has-read-permissions? perms) - can-comment (has-comment-permissions? perms)] - (when-not (or can-read can-comment) + (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id) + allowed? (if (= :share-link (:type perms)) + (has-comment-permissions? perms) + (or (has-read-permissions? perms) + (has-comment-permissions? perms)))] + (when-not allowed? (ex/raise :type :not-found :code :object-not-found - :hint "not found")))) + :hint "not found")) + perms)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUERY COMMANDS @@ -236,6 +241,18 @@ (some-> (db/get cfg :file-data {:file-id file-id :id fragment-id :type "fragment"}) (update :data blob/decode))) +(defn- check-fragment-scope! + "Checks that the fragment is reachable from the pages authorized by + the share-link. Raises a :not-found exception if the fragment is not reachable." + [cfg file-id fragment-id pages] + (let [fdata (-> (bfc/get-file cfg file-id :read-only? true) + (get :data) + (update :pages-index select-keys pages))] + (when-not (contains? (feat.fdata/get-used-pointer-ids fdata) fragment-id) + (ex/raise :type :not-found + :code :object-not-found + :hint "object not found")))) + (sv/defmethod ::get-file-fragment "Retrieve a file fragment by its ID. Only authenticated users." {::doc/added "1.17" @@ -246,6 +263,8 @@ (db/run! cfg (fn [cfg] (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)] (check-read-permissions! perms) + (when (= :share-link (:type perms)) + (check-fragment-scope! cfg file-id fragment-id (:pages perms))) (-> (get-file-fragment cfg file-id fragment-id) (rph/with-http-cache long-cache-duration)))))) @@ -392,6 +411,14 @@ (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id) file (bfc/get-file cfg file-id :read-only? true) + resolved-page-id (or page-id (-> file :data :pages first)) + + _ (when (and (= :share-link (:type perms)) + (not (contains? (:pages perms) resolved-page-id))) + (ex/raise :type :not-found + :code :object-not-found + :hint "object not found")) + proj (db/get conn :project {:id (:project-id file)}) team (-> (db/get conn :team {:id (:team-id proj)}) @@ -402,8 +429,7 @@ (cfeat/check-file-features! (:features file))) page (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)] - (let [page-id (or page-id (-> file :data :pages first)) - page (dm/get-in file [:data :pages-index page-id])] + (let [page (dm/get-in file [:data :pages-index resolved-page-id])] (if (pmap/pointer-map? page) (deref page) page)))] @@ -504,8 +530,11 @@ (def ^:private file-summary-cache-key-ttl (ct/duration {:days 30})) -(def file-summary-cache-key-prefix - "penpot.library-summary.") +(defn file-summary-cache-key + "Build the redis cache key for the file library summary. The tenant is + included to prevent key collisions between tenants sharing a redis instance" + [id] + (str "penpot.library-summary." (cf/get :tenant) "." id)) (defn- get-file-with-summary "Get a file without data with a summary of its local library content" @@ -534,7 +563,7 @@ (rds/build-set-args {:ex file-summary-cache-key-ttl})))] (if (contains? cf/flags :redis-cache) - (let [cache-key (str file-summary-cache-key-prefix id)] + (let [cache-key (file-summary-cache-key id)] (or (rds/run! cfg get-from-cache cache-key) (let [file (calculate-from-db)] (rds/run! cfg persist-to-cache (:library-summary file) cache-key) @@ -1069,6 +1098,25 @@ [cfg {:keys [::rpc/profile-id] :as params}] (db/tx-run! cfg delete-file (assoc params :profile-id profile-id))) +;; --- Library relation helpers + +(defn- check-library-team-ownership! + "Verify that file and library belong to the same team. + Prevents cross-team library relation injection." + [conn file-id library-id] + (let [sql "SELECT EXISTS ( + SELECT 1 FROM file AS f + JOIN project AS fp ON (fp.id = f.project_id) + JOIN file AS l ON (l.id = ?) + JOIN project AS lp ON (lp.id = l.project_id) + WHERE f.id = ? AND fp.team_id = lp.team_id + ) AS ok" + row (db/exec-one! conn [sql library-id file-id])] + (when-not (:ok row) + (ex/raise :type :not-found + :code :object-not-found + :hint "file and library must belong to the same team")))) + ;; --- MUTATION COMMAND: link-file-to-library (def sql:link-file-to-library @@ -1104,6 +1152,7 @@ (check-edition-permissions! conn profile-id file-id) (check-edition-permissions! conn profile-id library-id) + (check-library-team-ownership! conn file-id library-id) (let [transitive-deps (bfc/get-libraries cfg [library-id])] (when (contains? transitive-deps file-id) @@ -1135,6 +1184,7 @@ [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}] (check-edition-permissions! conn profile-id file-id) (check-edition-permissions! conn profile-id library-id) + (check-library-team-ownership! conn file-id library-id) (unlink-file-from-library conn params) nil) @@ -1159,6 +1209,7 @@ [{:keys [::db/conn]} {:keys [::rpc/profile-id file-id library-id] :as params}] (check-edition-permissions! conn profile-id file-id) (check-edition-permissions! conn profile-id library-id) + (check-library-team-ownership! conn file-id library-id) (update-sync conn params)) ;; --- MUTATION COMMAND: ignore-sync diff --git a/backend/src/app/rpc/commands/files_create.clj b/backend/src/app/rpc/commands/files_create.clj index dd297e223b..bb8f49694a 100644 --- a/backend/src/app/rpc/commands/files_create.clj +++ b/backend/src/app/rpc/commands/files_create.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.files-create (:require diff --git a/backend/src/app/rpc/commands/files_share.clj b/backend/src/app/rpc/commands/files_share.clj index bb925f243e..6643554a06 100644 --- a/backend/src/app/rpc/commands/files_share.clj +++ b/backend/src/app/rpc/commands/files_share.clj @@ -2,11 +2,13 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.files-share "Share link related rpc mutation methods." (:require + [app.binfile.common :as bfc] + [app.common.exceptions :as ex] [app.common.schema :as sm] [app.common.uuid :as uuid] [app.db :as db] @@ -43,7 +45,7 @@ [conn {:keys [profile-id file-id pages who-comment who-inspect]}] (let [pages (db/create-array conn "uuid" pages) slink (db/insert! conn :share-link - {:id (uuid/next) + {:id (uuid/random) :file-id file-id :who-comment who-comment :who-inspect who-inspect @@ -66,5 +68,16 @@ [{:keys [::db/conn]} {:keys [::rpc/profile-id id] :as params}] (let [slink (db/get-by-id conn :share-link id)] (files/check-edition-permissions! conn profile-id (:file-id slink)) + + ;; Verify caller owns this specific share-link, OR has admin access. + ;; Note: :is-admin already includes :is-owner (see bfc/get-file-permissions), + ;; so we only need to check :is-admin here. + (let [perms (bfc/get-file-permissions conn profile-id (:file-id slink))] + (when-not (or (= (:owner-id slink) profile-id) + (:is-admin perms)) + (ex/raise :type :authorization + :code :not-share-link-owner + :hint "You can only delete share-links you created"))) + (db/delete! conn :share-link {:id id}) nil)) diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj index 7baac52428..cb3e93285f 100644 --- a/backend/src/app/rpc/commands/files_snapshot.clj +++ b/backend/src/app/rpc/commands/files_snapshot.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.files-snapshot (:require diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index 46a4bc04ac..1a821c4027 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.files-thumbnails (:require @@ -21,7 +21,7 @@ [app.db.sql :as-alias sql] [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as-alias webhooks] - [app.media :as media] + [app.media.validation :as media.v] [app.rpc :as-alias rpc] [app.rpc.climit :as-alias climit] [app.rpc.commands.files :as files] @@ -275,7 +275,7 @@ [:map {:title "create-file-object-thumbnail"} [:file-id ::sm/uuid] [:object-id [:string {:max 250}]] - [:media media/schema:upload] + [:media media.v/schema:upload] [:tag {:optional true} [:string {:max 50}]]]) (sv/defmethod ::create-file-object-thumbnail @@ -289,8 +289,8 @@ ::sm/params schema:create-file-object-thumbnail} [cfg {:keys [::rpc/profile-id file-id object-id media tag]}] - (media/validate-media-type! media) - (media/validate-media-size! media) + (media.v/validate-media-type! media) + (media.v/validate-media-size! media) (db/run! cfg files/check-edition-permissions! profile-id file-id) (when-let [file (files/get-minimal-file cfg file-id {::db/check-deleted false})] @@ -299,30 +299,32 @@ ;; --- MUTATION COMMAND: delete-file-object-thumbnail (defn- delete-file-object-thumbnail! - [{:keys [::db/conn ::sto/storage]} file-id object-id] + [{:keys [::db/conn] :as cfg} file-id object-id] (when-let [{:keys [media-id tag]} (db/get* conn :file-tagged-object-thumbnail {:file-id file-id :object-id object-id} {::sql/for-update true})] - (sto/touch-object! storage media-id) - (db/update! conn :file-tagged-object-thumbnail - {:deleted-at (ct/now)} - {:file-id file-id - :object-id object-id - :tag tag}))) + (let [storage (sto/resolve cfg ::db/reuse-conn true)] + (sto/touch-object! storage media-id) + (db/update! conn :file-tagged-object-thumbnail + {:deleted-at (ct/now)} + {:file-id file-id + :object-id object-id + :tag tag})))) (defn- delete-file-object-thumbnails! "Soft-deletes multiple object thumbnails in a single UPDATE statement with RETURNING, then touches all returned media objects." - [{:keys [::db/conn ::sto/storage]} object-ids] - (let [ids (db/create-array conn "text" (seq object-ids)) - sql (str/concat - "UPDATE file_tagged_object_thumbnail" - " SET deleted_at = now()" - " WHERE object_id = ANY(?)" - " AND deleted_at IS NULL" - " RETURNING media_id") - rows (db/exec! conn [sql ids])] + [{:keys [::db/conn] :as cfg} object-ids] + (let [storage (sto/resolve cfg ::db/reuse-conn true) + ids (db/create-array conn "text" (seq object-ids)) + sql (str/concat + "UPDATE file_tagged_object_thumbnail" + " SET deleted_at = now()" + " WHERE object_id = ANY(?)" + " AND deleted_at IS NULL" + " RETURNING media_id") + rows (db/exec! conn [sql ids])] (doseq [{:keys [media-id]} rows] (sto/touch-object! storage media-id)))) @@ -342,10 +344,8 @@ ::audit/skip true} [cfg {:keys [::rpc/profile-id file-id object-id]}] (files/check-edition-permissions! cfg profile-id file-id) - (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] - (-> cfg - (update ::sto/storage sto/configure conn) - (delete-file-object-thumbnail! file-id object-id)) + (db/tx-run! cfg (fn [cfg] + (delete-file-object-thumbnail! cfg file-id object-id) nil))) (sv/defmethod ::delete-file-object-thumbnails @@ -366,11 +366,7 @@ (doseq [file-id file-ids] (files/check-edition-permissions! conn profile-id file-id)))) ;; Delete all matching thumbnails in one transaction - (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] - (-> cfg - (update ::sto/storage sto/configure conn) - (delete-file-object-thumbnails! object-ids)) - nil))))) + (db/tx-run! cfg delete-file-object-thumbnails! object-ids)))) ;; --- MUTATION COMMAND: create-file-thumbnail @@ -379,7 +375,7 @@ [:map {:title "create-file-thumbnail"} [:file-id ::sm/uuid] [:revn ::sm/int] - [:media media/schema:upload]]) + [:media media.v/schema:upload]]) (sv/defmethod ::create-file-thumbnail "Creates or updates the file thumbnail. Mainly used for paint the @@ -394,8 +390,8 @@ ::sm/params schema:create-file-thumbnail} [cfg {:keys [::rpc/profile-id file-id] :as params}] - (media/validate-media-type! (:media params)) - (media/validate-media-size! (:media params)) + (media.v/validate-media-type! (:media params)) + (media.v/validate-media-size! (:media params)) (db/run! cfg files/check-edition-permissions! profile-id file-id) diff --git a/backend/src/app/rpc/commands/files_update.clj b/backend/src/app/rpc/commands/files_update.clj index 0c19c1c315..519a32ba07 100644 --- a/backend/src/app/rpc/commands/files_update.clj +++ b/backend/src/app/rpc/commands/files_update.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.files-update (:require @@ -322,7 +322,7 @@ (defn- invalidate-caches! [cfg {:keys [id] :as file}] (rds/run! cfg (fn [{:keys [::rds/conn]}] - (let [key (str files/file-summary-cache-key-prefix id)] + (let [key (files/file-summary-cache-key id)] (rds/del conn key))))) (defn- attach-snapshot diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index 4d9eb77636..a35f4e7ce7 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.fonts (:require @@ -21,6 +21,7 @@ [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as-alias webhooks] [app.media :as media] + [app.media.validation :as media.v] [app.rpc :as-alias rpc] [app.rpc.climit :as-alias climit] [app.rpc.commands.files :as files] @@ -38,10 +39,7 @@ [datoteka.fs :as fs] [datoteka.io :as io]) (:import - java.io.InputStream java.io.OutputStream - java.io.SequenceInputStream - java.util.Collections java.util.zip.ZipEntry java.util.zip.ZipOutputStream)) @@ -95,31 +93,38 @@ (declare create-font-variant) +(defn- check-font-team-ownership! + "When font-id already has variants belonging to a different team, + raises :not-found to prevent cross-team font injection." + [conn team-id font-id] + (let [row (db/get* conn :team-font-variant + {:font-id font-id} + {::db/columns [:team-id]})] + (when (and row (not= (:team-id row) team-id)) + (ex/raise :type :not-found + :code :object-not-found + :hint "font does not belong to this team")))) + (def ^:private schema:create-font-variant - [:and - [:map {:title "create-font-variant"} - [:team-id ::sm/uuid] - [:font-id ::sm/uuid] - [:font-family types.font/schema:font-family] - [:font-weight [::sm/one-of {:format "number"} valid-weight]] - [:font-style [::sm/one-of {:format "string"} valid-style]] - [:data {:optional true} [:map-of ::sm/text [:or ::sm/bytes [::sm/vec ::sm/bytes]]]] - [:uploads {:optional true} [:map-of ::sm/text ::sm/uuid]]] - [:fn {:error/message "one of :data or :uploads is required"} - (fn [{:keys [data uploads]}] - (or (seq data) (seq uploads)))]]) + [:map {:title "create-font-variant"} + [:team-id ::sm/uuid] + [:font-id ::sm/uuid] + [:font-family types.font/schema:font-family] + [:font-weight [::sm/one-of {:format "number"} valid-weight]] + [:font-style [::sm/one-of {:format "string"} valid-style]] + [:uploads [:map-of ::sm/text ::sm/uuid]]]) (defn- prepare-font-data-from-uploads "Assembles each chunked-upload session in `uploads` (a `{mtype → session-id}` map) into a temp file, validates the media type and size of every entry, and returns a `{mtype → path}` data map." - [cfg {:keys [uploads] :as params}] + [cfg {:keys [::rpc/profile-id uploads] :as params}] (let [data (reduce-kv (fn [acc mtype session-id] - (let [assembled (assemble-chunks cfg session-id)] + (let [assembled (assemble-chunks cfg profile-id session-id)] (-> {:mtype mtype :size (:size assembled)} - (media/validate-media-type! cm/font-types) - (media/validate-font-size!)) + (media.v/validate-media-type! cm/font-types) + (media.v/validate-font-size!)) (assoc acc mtype (:path assembled)))) {} uploads)] @@ -128,54 +133,24 @@ (assoc :data data) (dissoc :uploads)))) -(defn- prepare-font-data-from-legacy - "Validates the media type and size of every entry in the legacy - `:data` map (a `{mtype → bytes | [bytes]}` map). Normalises every - entry to a tempfile. Returns params with a normalised - `{mtype → path}` data map." - [{:keys [data] :as params}] - (let [data (reduce-kv - (fn [acc mtype content] - (let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "") - chunks (if (vector? content) content [content]) - streams (map io/input-stream chunks) - streams (Collections/enumeration streams)] - - ;; Generate the tempfile from all chunks - (with-open [^OutputStream output (io/output-stream tmp) - ^InputStream input (SequenceInputStream. streams)] - (io/copy input output)) - - ;; Validate - (-> {:mtype mtype :size (fs/size tmp)} - (media/validate-media-type! cm/font-types) - (media/validate-font-size!)) - - (assoc acc mtype tmp))) - {} - data)] - (assoc params :data data))) - (sv/defmethod ::create-font-variant - "Upload a font variant. Font data may be provided either as a - Transit-encoded `:data` map (keyed by mime-type) for small fonts, or - as an `:uploads` map (keyed by mime-type, values are upload-session - UUIDs from the chunked-upload API) for large fonts. Exactly one of - the two must be present." + "Upload a font variant. Font data must be provided as an `:uploads` + map (keyed by mime-type, values are upload-session UUIDs from the + chunked-upload API)." {::doc/added "1.18" - ::doc/changes ["2.16" "Add :uploads param for chunked upload support"] + ::doc/changes [["2.16" "Add :uploads param for chunked upload support"] + ["2.18" "Remove :data param, use :uploads exclusively"]] ::climit/id [[:process-font/by-profile ::rpc/profile-id] [:process-font/global]] ::webhooks/event? true ::sm/params schema:create-font-variant} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id uploads] :as params}] + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id font-id] :as params}] (teams/check-edition-permissions! pool profile-id team-id) + (check-font-team-ownership! pool team-id font-id) (quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team ::quotes/profile-id profile-id ::quotes/team-id team-id}) - (let [params (if (some? uploads) - (db/tx-run! cfg prepare-font-data-from-uploads params) - (prepare-font-data-from-legacy params))] + (let [params (db/tx-run! cfg prepare-font-data-from-uploads params)] (create-font-variant cfg (assoc params :profile-id profile-id)))) (defn create-font-variant @@ -229,9 +204,7 @@ (let [tpoint (ct/tpoint) mtypes (vec (keys data)) total-size (reduce-kv (fn [acc _ content] - (+ acc (if (bytes? content) - (alength ^bytes content) - (fs/size content)))) + (+ acc (fs/size content))) 0 data)] @@ -370,7 +343,7 @@ (defn- make-temporal-storage-object [cfg profile-id content] (let [storage (sto/resolve cfg) - content (media/check-input content) + content (media.v/check-input content) hash (sto/calculate-hash (:path content)) data (-> (sto/content (:path content)) (sto/wrap-with-hash hash)) @@ -380,7 +353,7 @@ ::sto/touched-at (ct/in-future {:minutes 30}) :profile-id profile-id :content-type mtype - :bucket "tempfile"}] + :bucket sto/tempfile-bucket}] (sto/put-object! storage content))) diff --git a/backend/src/app/rpc/commands/ldap.clj b/backend/src/app/rpc/commands/ldap.clj index 6620e28b30..20321d3d0a 100644 --- a/backend/src/app/rpc/commands/ldap.clj +++ b/backend/src/app/rpc/commands/ldap.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.ldap (:require diff --git a/backend/src/app/rpc/commands/management.clj b/backend/src/app/rpc/commands/management.clj index 41931f53ec..ecb805d4a3 100644 --- a/backend/src/app/rpc/commands/management.clj +++ b/backend/src/app/rpc/commands/management.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.management "A collection of RPC methods for manage the files, projects and team organization." @@ -425,8 +425,11 @@ cfg (-> cfg (assoc ::bfc/project-id project-id) (assoc ::bfc/profile-id profile-id) + (assoc ::bfc/team-id (:id team)) (assoc ::bfc/input template) - (assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team))) + (assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team)) + (assoc ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size)) + (assoc ::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))) result (if (= format :binfile-v3) (bf.v3/import-files! cfg) diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index ff8add456a..7bf42c57a9 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.media (:require @@ -16,6 +16,8 @@ [app.db :as db] [app.loggers.audit :as-alias audit] [app.media :as media] + [app.media.svg :as svg] + [app.media.validation :as media.v] [app.rpc :as-alias rpc] [app.rpc.climit :as climit] [app.rpc.commands.files :as files] @@ -38,13 +40,19 @@ (declare create-file-media-object) +(def ^:private sql:get-team-id-for-file + "SELECT p.team_id + FROM file AS f + JOIN project AS p ON (p.id = f.project_id) + WHERE f.id = ?") + (def ^:private schema:upload-file-media-object [:map {:title "upload-file-media-object"} [:id {:optional true} ::sm/uuid] [:file-id ::sm/uuid] [:is-local ::sm/boolean] [:name [:string {:max 250}]] - [:content media/schema:upload]]) + [:content media.v/schema:upload]]) (sv/defmethod ::upload-file-media-object {::doc/added "1.17" @@ -53,8 +61,14 @@ [:process-image/global]]} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}] (files/check-edition-permissions! pool profile-id file-id) - (media/validate-media-type! content) - (media/validate-media-size! content) + (media.v/validate-media-type! content) + (media.v/validate-media-size! content) + + (let [team-id (:team-id (db/exec-one! pool [sql:get-team-id-for-file file-id]))] + (quotes/check! cfg {::quotes/id ::quotes/media-storage-bytes-per-team + ::quotes/profile-id profile-id + ::quotes/team-id team-id + ::quotes/incr (:size content)})) (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] ;; We get the minimal file for proper checking if @@ -113,13 +127,22 @@ (defn- process-main-image [info] - (let [hash (sto/calculate-hash (:path info)) - data (-> (sto/content (:path info)) - (sto/wrap-with-hash hash))] + (let [path (:path info) + mtype (:mtype info) + path (if (= mtype "image/svg+xml") + (let [content (slurp path) + sanitized (svg/sanitize-svg content) + temp-path (tmp/tempfile :prefix "penpot-svg-" :suffix ".svg" :min-age "5m")] + (spit (str temp-path) sanitized) + temp-path) + path) + hash (sto/calculate-hash path) + data (-> (sto/content path) + (sto/wrap-with-hash hash))] {::sto/content data ::sto/deduplicate? true ::sto/touched-at (:ts info) - :content-type (:mtype info) + :content-type mtype :bucket "file-media-object"})) (defn- process-thumb-image @@ -261,8 +284,13 @@ (clone-file-media-object cfg params)) (defn clone-file-media-object - [{:keys [::db/conn]} {:keys [id file-id is-local]}] + [{:keys [::db/conn] :as cfg} {:keys [id file-id is-local] :as params}] (let [mobj (db/get-by-id conn :file-media-object id)] + (when-not mobj + (ex/raise :type :not-found + :code :object-not-found + :hint "source media object not found")) + (files/check-read-permissions! conn (::rpc/profile-id params) (:file-id mobj)) (db/insert! conn :file-media-object {:id (uuid/next) :file-id file-id @@ -278,7 +306,7 @@ (def ^:private schema:create-upload-session [:map {:title "create-upload-session"} - [:total-chunks ::sm/int]]) + [:total-chunks [::sm/int {:min 1}]]]) (def ^:private schema:create-upload-session-result [:map {:title "create-upload-session-result"} @@ -315,7 +343,7 @@ [:map {:title "upload-chunk"} [:session-id ::sm/uuid] [:index ::sm/int] - [:content media/schema:upload]]) + [:content media.v/schema:upload]]) (def ^:private schema:upload-chunk-result [:map {:title "upload-chunk-result"} @@ -349,9 +377,9 @@ (sto/put-object! storage {::sto/content data ::sto/deduplicate? false - ::sto/touch true + ::sto/touched-at (ct/in-future {:hours 1}) :content-type (:mtype content) - :bucket "tempfile" + :bucket sto/tempfile-bucket :upload-id (str session-id) :chunk-index index})) @@ -365,6 +393,7 @@ FROM storage_object WHERE (metadata->>'~:upload-id') = ?::text AND deleted_at IS NULL + AND status = 'valid' ORDER BY (metadata->>'~:chunk-index')::integer ASC") (defn- get-upload-chunks @@ -386,14 +415,15 @@ (defn assemble-chunks "Validates that all expected chunks are present for `session-id` and concatenates them into a single temporary file. Returns a map - conforming to `media/schema:upload` with `:filename`, `:path` and + conforming to `media.v/schema:upload` with `:filename`, `:path` and `:size`. Raises a :validation/:missing-chunks error when the number of stored chunks does not match `:total-chunks` recorded in the session row. + Raises :not-found when the session does not belong to `profile-id`. Deletes the session row from `upload_session` on success." - [{:keys [::db/conn] :as cfg} session-id] - (let [session (db/get conn :upload-session {:id session-id}) + [{:keys [::db/conn] :as cfg} profile-id session-id] + (let [session (db/get conn :upload-session {:id session-id :profile-id profile-id}) chunks (get-upload-chunks conn session-id)] (when (not= (count chunks) (:total-chunks session)) @@ -436,12 +466,12 @@ (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] - (let [content (assemble-chunks cfg session-id) + (let [content (assemble-chunks cfg profile-id session-id) content (-> content (assoc :filename (str "upload:" name)) (assoc :mtype mtype) - (media/validate-media-type!) - (media/validate-media-size!)) + (media.v/validate-media-type!) + (media.v/validate-media-size!)) mobj (create-file-media-object cfg (assoc params :id id :from-chunks? true diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index b7143ebe85..87b9409f27 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.nitrate "Nitrate API for Penpot. Provides nitrate-related endpoints to be called @@ -41,17 +41,6 @@ (ex/raise :type :validation :code :cant-move-default-team)))) -(defn assert-membership [cfg profile-id organization-id] - (let [membership (nitrate/call cfg :get-organization-membership {:profile-id profile-id - :organization-id organization-id})] - (when-not (:organization-id membership) - (ex/raise :type :validation - :code :organization-does-not-exist)) - - (when-not (:is-member membership) - (ex/raise :type :validation - :code :user-doesnt-belong-organization)))) - (def schema:connectivity [:map {:title "nitrate-connectivity"} @@ -59,7 +48,7 @@ (sv/defmethod ::get-nitrate-connectivity {::rpc/auth true - ::doc/added "2.14" + ::doc/added "2.18" ::sm/params [:map] ::sm/result schema:connectivity} [cfg _params] @@ -75,7 +64,7 @@ (sv/defmethod ::get-subscription-warning {::rpc/auth true - ::doc/added "2.14" + ::doc/added "2.18" ::sm/params [:map] ::sm/result schema:subscription-warning} [cfg {:keys [::rpc/profile-id]}] @@ -91,7 +80,7 @@ (sv/defmethod ::redeem-nitrate-activation-code {::rpc/auth true - ::doc/added "2.14" + ::doc/added "2.18" ::sm/params schema:redeem-activation-code-params ::sm/result schema:redeem-activation-code-result} [cfg {:keys [::rpc/profile-id activation-code]}] @@ -112,6 +101,7 @@ (ex/raise :type :validation :code (case status 410 :expired-activation-code + 409 :used-activation-code :invalid-activation-code) :cause cause) (throw cause))))))) @@ -123,7 +113,7 @@ "Returns a Base64-encoded JSON file requesting a Nitrate activation code. Payload includes nitrateId, publicKey, email and iat." {::rpc/auth true - ::doc/added "2.20" + ::doc/added "2.18" ::sm/params [:map] ::sm/result ::sm/text} [cfg {:keys [::rpc/profile-id]}] @@ -335,7 +325,7 @@ (when-not skip-validation (assert-valid-teams cfg profile-id id default-team-id teams-to-delete teams-to-leave)) - (assert-membership cfg profile-id id) + (nitrate/assert-membership cfg profile-id id) ;; delete only eligible teams (non-protected and without files) (doseq [id deletable-team-ids] @@ -346,7 +336,7 @@ (doseq [{:keys [id reassign-to]} teams-to-leave] (teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to})) - ;; Process organization "Your Penpot" team: keep with prefix if needed, otherwise delete. + ;; Process organization "Personal Projects" team: keep with prefix if needed, otherwise delete. (when default-team-id (if keep-default-team? (db/exec! conn [sql:prefix-team-name-and-unset-default organization-prefix default-team-id]) @@ -371,7 +361,7 @@ (sv/defmethod ::leave-organization {::rpc/auth true - ::doc/added "2.15" + ::doc/added "2.18" ::sm/params schema:leave-organization ::db/transaction true} [cfg {:keys [::rpc/profile-id] :as params}] @@ -415,13 +405,13 @@ [:organization-name ::sm/text]]) (sv/defmethod ::remove-team-from-organization - {::doc/added "2.17" + {::doc/added "2.18" ::sm/params schema:remove-team-from-organization} [cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}] (assert-is-owner cfg profile-id team-id) (assert-not-default-team cfg team-id) - (assert-membership cfg profile-id organization-id) + (nitrate/assert-membership cfg profile-id organization-id) ;; Check moveTeams permission on the source organization (when (contains? cf/flags :admin-console) (let [organization-perms (nitrate/call cfg :get-organization-permissions @@ -468,13 +458,14 @@ (let [emails (map :email (noh/get-team-invitation-emails conn team-id))] (if (empty? emails) {:allows-anybody false :external-emails []} - (let [emails-array (db/create-array conn "text" (vec emails)) - profiles (db/exec! conn [sql:get-profiles-by-emails emails-array]) + (let [emails-array (db/create-array conn "text" (vec emails)) + profiles (db/exec! conn [sql:get-profiles-by-emails emails-array]) organization-member-ids (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id})) - external-emails (->> profiles - (remove #(contains? organization-member-ids (:id %))) - (map :email) - (vec))] + member-emails (->> profiles + (filter #(contains? organization-member-ids (:id %))) + (map :email) + (into #{})) + external-emails (into [] (remove member-emails emails))] {:allows-anybody false :external-emails external-emails})))))) (def ^:private schema:add-team-to-organization @@ -484,14 +475,14 @@ (sv/defmethod ::add-team-to-organization {::rpc/auth true - ::doc/added "2.17" + ::doc/added "2.18" ::sm/params schema:add-team-to-organization ::db/transaction true} [cfg {:keys [::rpc/profile-id team-id organization-id]}] (assert-is-owner cfg profile-id team-id) (assert-not-default-team cfg team-id) - (assert-membership cfg profile-id organization-id) + (nitrate/assert-membership cfg profile-id organization-id) (when (contains? cf/flags :admin-console) (let [organization-member-ids-before (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id})) @@ -569,13 +560,13 @@ (sv/defmethod ::check-organization-members {::rpc/auth true - ::doc/added "2.17" + ::doc/added "2.18" ::sm/params schema:check-organization-members-params ::sm/result [:map-of :string :boolean] ::db/transaction true} [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}] (or (when (contains? cf/flags :admin-console) - (assert-membership cfg profile-id organization-id) + (nitrate/assert-membership cfg profile-id organization-id) (let [emails-array (db/create-array conn "text" emails) profiles (db/exec! conn [sql:get-profiles-by-emails emails-array]) email->id (into {} (map (fn [p] [(:email p) (:id p)])) profiles) @@ -594,7 +585,7 @@ (sv/defmethod ::all-organization-members-in-team {::rpc/auth true - ::doc/added "2.17" + ::doc/added "2.18" ::sm/params schema:all-organization-members-in-team-params ::sm/result ::sm/boolean} [cfg {:keys [::rpc/profile-id team-id organization-id]}] @@ -603,7 +594,7 @@ (when-not (or (:is-admin perms) (:is-owner perms)) (ex/raise :type :validation :code :insufficient-permissions)) - (assert-membership cfg profile-id organization-id) + (nitrate/assert-membership cfg profile-id organization-id) (let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id}) organization-member-ids (into #{} organization-members) team-members (db/query cfg :team-profile-rel {:team-id team-id}) @@ -618,7 +609,7 @@ (sv/defmethod ::all-team-members-in-organizations {::rpc/auth true - ::doc/added "2.17" + ::doc/added "2.18" ::sm/params schema:all-team-members-in-organizations-params ::sm/result [:map-of ::sm/uuid ::sm/boolean]} [cfg {:keys [::rpc/profile-id team-id organization-ids]}] @@ -631,7 +622,7 @@ (let [team-members (db/query cfg :team-profile-rel {:team-id team-id}) team-member-ids (into #{} (map :profile-id team-members))] ;; Validate requester membership in all organizations before fetching members. - (run! #(assert-membership cfg profile-id %) organization-ids) + (run! #(nitrate/assert-membership cfg profile-id %) organization-ids) (into {} (map (fn [organization-id] @@ -654,7 +645,7 @@ (sv/defmethod ::check-team-external-invitations {::rpc/auth true - ::doc/added "2.17" + ::doc/added "2.18" ::sm/params schema:check-team-external-invitations-params ::sm/result schema:check-team-external-invitations-result ::db/transaction true} @@ -664,7 +655,7 @@ (when-not (or (:is-admin perms) (:is-owner perms)) (ex/raise :type :validation :code :insufficient-permissions)) - (assert-membership cfg profile-id organization-id) + (nitrate/assert-membership cfg profile-id organization-id) (let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)] {:has-external-invitations (boolean (seq external-emails)) :allows-anybody allows-anybody})) @@ -683,12 +674,17 @@ (sv/defmethod ::check-nitrate-sso "Check if a user needs to login into the organization SSO. Accepts either team-id (to look up the organization via the team) or organization-id directly. - Returns {:authorized true} when SSO is not active or the user cannot access the team. + Returns {:authorized true :reason :sso-satisfied} when SSO is not active or the + session already holds a valid entry for the organization, and + {:authorized true :reason :no-team-access} when the gate was skipped because the + user cannot access the team; the reason lets the client tell a usable session + apart from a plain permission failure. Returns {:authorized false :redirect-uri <url>} when SSO is active; the client must redirect there. The OIDC provider itself handles - re-authentication transparently if the user already has an active SSO session." + re-authentication transparently if the user already has an active SSO session. + A nil :redirect-uri means SSO is required but the provider is not usable." {::rpc/auth true - ::doc/added "2.19" + ::doc/added "2.18" ::sm/params schema:check-nitrate-sso ::nitrate/sso false} [cfg {:keys [::rpc/profile-id team-id organization-id url] :as params}] @@ -697,16 +693,25 @@ (not (teams/has-read-permissions? cfg profile-id team-id))) ;; Let the destination RPC enforce its own permissions. Starting SSO before ;; access is established sends unrelated users through the organization's IdP. - {:authorized true} + {:authorized true :reason :no-team-access} (let [request (rph/get-request params) {:keys [authorized sso]} (nitrate/sso-session-authorized? cfg organization-id team-id request)] (if authorized - {:authorized true} + {:authorized true :reason :sso-satisfied} (if (oidc/organization-sso-discovery-uri sso) - {:authorized false - :redirect-uri (oidc/build-organization-sso-auth-redirect-uri cfg sso - :dest-url url - :organization-id organization-id)} + (try + (let [redirect-uri (oidc/build-organization-sso-auth-redirect-uri + cfg sso + :dest-url url + :organization-id organization-id) + organization-id (or organization-id (:organization-id sso))] + (oidc/submit-organization-sso-auth-started-event + cfg request profile-id organization-id) + {:authorized false :redirect-uri redirect-uri}) + (catch Throwable cause + (oidc/submit-organization-sso-auth-failed-event + cfg request profile-id (or organization-id (:organization-id sso)) cause) + (throw cause))) {:authorized false :redirect-uri nil})))) - {:authorized true})) + {:authorized true :reason :sso-satisfied})) diff --git a/backend/src/app/rpc/commands/plugins.clj b/backend/src/app/rpc/commands/plugins.clj new file mode 100644 index 0000000000..989f59ef36 --- /dev/null +++ b/backend/src/app/rpc/commands/plugins.clj @@ -0,0 +1,75 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.rpc.commands.plugins + (:require + [app.common.exceptions :as ex] + [app.common.schema :as sm] + [app.common.types.plugins :as ctp] + [app.db :as db] + [app.rpc :as-alias rpc] + [app.rpc.commands.profile :as profile] + [app.rpc.doc :as-alias doc] + [app.util.services :as sv])) + +(defn- validate-plugin-permissions! + "Validates that all permissions in the plugin are within the valid set." + [plugin] + (let [permissions (:permissions plugin) + invalid (remove ctp/valid-permissions permissions)] + (when (seq invalid) + (ex/raise :type :validation + :code :invalid-plugin-permissions + :hint (str "Invalid permissions: " (pr-str (set invalid))) + :invalid-permissions (set invalid))))) + +(def ^:private + schema:add-profile-plugin + [:map {:title "add-profile-plugin"} + [:plugin ctp/schema:registry-entry]]) + +(sv/defmethod ::add-profile-plugin + {::doc/added "2.18" + ::sm/params schema:add-profile-plugin + ::sm/result ctp/schema:registry-entry + ::db/transaction true} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id plugin]}] + (validate-plugin-permissions! plugin) + + (let [profile (profile/get-profile conn profile-id ::db/for-update true) + plugins (get-in profile [:props :plugins] {:ids [] :data {}}) + plugin-id (:plugin-id plugin) + plugins (-> plugins + (update :ids #(vec (distinct (conj % plugin-id)))) + (assoc-in [:data plugin-id] plugin))] + (db/update! conn :profile + {:props (db/tjson (assoc (:props profile) :plugins plugins))} + {:id profile-id} + {::db/return-keys false}) + plugin)) + +(def ^:private + schema:remove-profile-plugin + [:map {:title "remove-profile-plugin"} + [:plugin-id ::sm/uuid]]) + +(sv/defmethod ::remove-profile-plugin + {::doc/added "2.18" + ::sm/params schema:remove-profile-plugin + ::sm/result :nil + ::db/transaction true} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id plugin-id]}] + (let [profile (profile/get-profile conn profile-id ::db/for-update true) + plugins (get-in profile [:props :plugins] {:ids [] :data {}}) + plugin-id-str (str plugin-id) + plugins (-> plugins + (update :ids #(vec (remove (partial = plugin-id-str) %))) + (update :data dissoc plugin-id-str))] + (db/update! conn :profile + {:props (db/tjson (assoc (:props profile) :plugins plugins))} + {:id profile-id} + {::db/return-keys false}) + nil)) diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index a26fc9ea9e..6d878e0ebd 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -2,16 +2,17 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.profile (:require [app.auth :as auth] + [app.auth.passwords :as passwords] [app.common.data :as d] [app.common.exceptions :as ex] [app.common.schema :as sm] [app.common.time :as ct] - [app.common.types.plugins :refer [schema:plugin-registry]] + [app.common.types.plugins :as ctp] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] @@ -21,6 +22,7 @@ [app.loggers.audit :as audit] [app.main :as-alias main] [app.media :as media] + [app.media.validation :as media.v] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.climit :as climit] @@ -52,11 +54,11 @@ (def system-managed-props "Props keys managed by the system (not user-writable via RPC)." - #{:subscription}) + #{:subscription :plugins}) (def schema:props [:map {:title "ProfileProps" :closed true} - [:plugins {:optional true} schema:plugin-registry] + [:plugins {:optional true} ctp/schema:plugin-registry] [:renderer {:optional true} [::sm/one-of #{:svg :wasm}]] [:mcp-enabled {:optional true} ::sm/boolean] [:newsletter-updates {:optional true} ::sm/boolean] @@ -76,6 +78,10 @@ [:map-of {:gen/max 10} :keyword [:map-of :keyword :string]]] [:nudge {:optional true} schema:nudge]]) +(def schema:props-writeable + "Props schema for user-writable fields (excludes system-managed keys)." + (reduce sm/dissoc-key schema:props system-managed-props)) + (def schema:profile [:map {:title "Profile"} [:id ::sm/uuid] @@ -139,9 +145,7 @@ (defn get-profile "Get profile by id. Throws not-found exception if no profile found." [conn id & {:as opts}] - ;; NOTE: We need to set ::db/remove-deleted to false because demo profiles - ;; are created with a set deleted-at value - (-> (db/get-by-id conn :profile id (assoc opts ::db/remove-deleted false)) + (-> (db/get-by-id conn :profile id opts) (decode-row))) ;; --- MUTATION: Update Profile (own) @@ -163,6 +167,13 @@ ;; it or not for explicit locking and avoid concurrent updates of ;; the same row/object. (let [profile (get-profile conn profile-id ::db/for-update true) + fullname (d/normalize-string fullname) + lang (if (contains? params :lang) + (d/normalize-string lang) + (:lang profile)) + theme (if (contains? params :theme) + (d/normalize-string theme) + (:theme profile)) ;; Update the profile map with direct params profile (-> profile (assoc :fullname fullname) @@ -208,6 +219,9 @@ :code :email-as-password :hint "you can't use your email as password")) + ;; Validate password strength against common password dictionary + (passwords/validate-password (:password params)) + (update-profile-password! cfg (assoc profile :password password)) (->> (rph/get-request params) @@ -280,7 +294,7 @@ (def ^:private schema:update-profile-photo [:map {:title "update-profile-photo"} - [:file media/schema:upload]]) + [:file media.v/schema:upload]]) (sv/defmethod ::update-profile-photo {:doc/added "1.1" @@ -288,8 +302,8 @@ ::sm/result :nil} [cfg {:keys [::rpc/profile-id file] :as params}] ;; Validate incoming mime type - (media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) - (media/validate-media-size! file) + (media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) + (media.v/validate-media-size! file) (update-profile-photo cfg (assoc params :profile-id profile-id))) (defn update-profile-photo @@ -453,7 +467,7 @@ (def ^:private schema:update-profile-props [:map {:title "update-profile-props"} - [:props schema:props]]) + [:props schema:props-writeable]]) (defn update-profile-props [{:keys [::db/conn] :as cfg} profile-id props] @@ -512,7 +526,7 @@ ;; Penpot back through two paths: ::notify-user-organizations-deletion ;; (during delete-owned-organizations) and ::notify-organization-deletion. ;; Both preserve organization teams unchanged and only prefix or delete - ;; imported "Your Penpot" teams according to whether they still have files. + ;; imported "Personal Projects" teams according to whether they still have files. ;; Let Nitrate clean up the data associated with the deleted Penpot user: ;; owned organizations, remaining memberships, and subscription cancellation. (when (contains? cf/flags :admin-console) @@ -526,6 +540,10 @@ :deleted-at deleted-at :id profile-id}}) + ;; Invalidate all sessions for this profile to ensure immediate + ;; access revocation across all devices + (session/invalidate-all cfg profile-id) + (-> (rph/wrap nil) (rph/with-transform (session/delete-fn cfg))))) diff --git a/backend/src/app/rpc/commands/projects.clj b/backend/src/app/rpc/commands/projects.clj index 12da9bb7c5..618ee5cd01 100644 --- a/backend/src/app/rpc/commands/projects.clj +++ b/backend/src/app/rpc/commands/projects.clj @@ -2,10 +2,11 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.projects (:require + [app.common.data :as d] [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.schema :as sm] @@ -259,7 +260,8 @@ ::db/transaction true} [{:keys [::db/conn]} {:keys [::rpc/profile-id id name] :as params}] (check-edition-permissions! conn profile-id id) - (let [project (db/get-by-id conn :project id ::sql/for-update true)] + (let [project (db/get-by-id conn :project id ::sql/for-update true) + name (d/normalize-string name)] (db/update! conn :project {:name name} {:id id}) diff --git a/backend/src/app/rpc/commands/search.clj b/backend/src/app/rpc/commands/search.clj index 7b60e6db30..1186b15554 100644 --- a/backend/src/app/rpc/commands/search.clj +++ b/backend/src/app/rpc/commands/search.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.search (:require diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 76d9b162c5..5266feb4b5 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.teams (:require @@ -22,7 +22,7 @@ [app.features.logical-deletion :as ldel] [app.loggers.audit :as audit] [app.main :as-alias main] - [app.media :as media] + [app.media.validation :as media.v] [app.msgbus :as mbus] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] @@ -196,11 +196,11 @@ ::sm/params schema:get-teams} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}] (dm/with-open [conn (db/open pool)] - (cond->> (get-teams conn profile-id) - (contains? cf/flags :admin-console) - (map #(nitrate/add-organization-info-to-team cfg % params)) - (contains? cf/flags :admin-console) - (remove #(get-in % [:organization :expired-license]))))) + (let [teams (get-teams conn profile-id)] + (if (contains? cf/flags :admin-console) + (->> (nitrate/add-organization-info-to-teams cfg teams params) + (remove #(get-in % [:organization :expired-license]))) + teams)))) (def ^:private sql:get-owned-teams "SELECT t.id, t.name, @@ -538,6 +538,9 @@ ;; When creating inside an organization, verify the user has permission to do so. ;; Fail closed: if organization permissions cannot be fetched, deny the operation. (when (and organization-id (contains? cf/flags :admin-console)) + ;; Verify caller is a member of the organization + (nitrate/assert-membership cfg profile-id organization-id) + (let [organization-perms (nitrate/call cfg :get-organization-permissions {:organization-id organization-id})] (if (nil? organization-perms) @@ -572,7 +575,7 @@ (set/difference cfeat/frontend-only-features) (set/difference cfeat/no-team-inheritable-features)) params {:profile-id profile-id - :name "Your Penpot" + :name "Personal Projects" :features features :organization-id organization-id :is-default true} @@ -652,6 +655,7 @@ (let [id (or id (uuid/next)) is-default (if (boolean? is-default) is-default false) features (db/create-array conn "text" features) + name (d/normalize-string name) team (db/insert! conn :team {:id id :name name @@ -688,6 +692,7 @@ [conn {:keys [id team-id name is-default created-at modified-at]}] (let [id (or id (uuid/next)) is-default (if (boolean? is-default) is-default false) + name (d/normalize-string name) params {:id id :name name :team-id team-id @@ -718,9 +723,10 @@ ::db/transaction true} [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id name]}] (check-edition-permissions! conn profile-id id) - (db/update! conn :team - {:name name} - {:id id}) + (let [name (d/normalize-string name)] + (db/update! conn :team + {:name name} + {:id id})) nil) @@ -823,7 +829,7 @@ :code :only-owner-can-delete-team)) ;; Protect the user's personal default team from deletion. - ;; Organization-scoped default teams ("Your Penpot") are allowed to be deleted when they have no files. + ;; Organization-scoped default teams ("Personal Projects") are allowed to be deleted when they have no files. (when (and (:is-default team) (not in-organization?)) (ex/raise :type :validation :code :non-deletable-team @@ -938,8 +944,10 @@ ::sm/params schema:delete-team-member ::db/transaction true} [{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id team-id member-id] :as params}] - (let [team (get-team conn :profile-id profile-id :team-id team-id) - perms (get-permissions conn profile-id team-id)] + (let [team (get-team conn :profile-id profile-id :team-id team-id) + perms (get-permissions conn profile-id team-id) + members (get-team-members conn team-id) + member (d/seek #(= member-id (:id %)) members)] (when-not (or (:is-owner perms) (:is-admin perms)) (ex/raise :type :validation @@ -949,6 +957,15 @@ (ex/raise :type :validation :code :cant-remove-yourself)) + (when-not member + (ex/raise :type :not-found + :code :member-does-not-exist)) + + (when (and (:is-owner member) + (not (:is-owner perms))) + (ex/raise :type :validation + :code :cant-remove-owner)) + (db/delete! conn :team-profile-rel {:profile-id member-id :team-id team-id}) @@ -979,7 +996,7 @@ (def ^:private schema:update-team-photo [:map {:title "update-team-photo"} [:team-id ::sm/uuid] - [:file media/schema:upload]]) + [:file media.v/schema:upload]]) (sv/defmethod ::update-team-photo {::doc/added "1.17" @@ -987,8 +1004,8 @@ [cfg {:keys [::rpc/profile-id file] :as params}] ;; Validate incoming mime type - (media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) - (media/validate-media-size! file) + (media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) + (media.v/validate-media-size! file) (update-team-photo cfg (assoc params :profile-id profile-id))) (defn update-team-photo diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index 8b1a8c357c..740b38a59b 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.teams-invitations (:require @@ -46,10 +46,29 @@ (def sql:upsert-organization-invitation "insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until) - values (?, null, ?, ?, ?, ?, ?) - on conflict(org_id, email_to) where team_id is null do - update set role = ?, valid_until = ?, updated_at = now() - returning *") + values (?, null, ?, ?, ?, ?, ?) + on conflict(org_id, email_to) where team_id is null do + update set role = ?, valid_until = ?, updated_at = now() + returning *") + +(def ^:private sql:check-recent-invitation + "SELECT 1 FROM team_invitation + WHERE team_id = ? AND email_to = ? + AND updated_at > now() - interval '5 minutes' + LIMIT 1") + +(def ^:private sql:check-recent-org-invitation + "SELECT 1 FROM team_invitation + WHERE org_id = ? AND email_to = ? + AND updated_at > now() - interval '5 minutes' + LIMIT 1") + +(defn- recently-invited? + [{:keys [::db/conn]} team-id org-id email] + (let [query (if org-id + [sql:check-recent-org-invitation org-id email] + [sql:check-recent-invitation team-id email])] + (some? (db/exec-one! conn query)))) (defn- create-invitation-token [cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}] @@ -89,14 +108,7 @@ (def ^:private schema:create-organization-invitation [:map {:title "params:create-organization-invitation"} [::rpc/profile-id ::sm/uuid] - [:organization - [:map - [:id ::sm/uuid] - [:name :string] - [:initials [:maybe :string]] - [:logo ::sm/uri] - [:avatar-bg-url [:maybe ::sm/uri]] - [:sso-active [:maybe ::sm/boolean]]]] + [:organization cto/schema:organization-with-avatar] [:profile [:map [:id ::sm/uuid] @@ -185,35 +197,36 @@ (teams/check-email-bounce conn email true) (teams/check-email-spam conn email true) - (let [id (uuid/next) - expire (if organization - (ct/in-future "876000h") ;; Organization invitations doesn't expire - (ct/in-future "168h")) ;; 7 days - invitation (db/exec-one! conn (if organization - [sql:upsert-organization-invitation id - (:id organization) - (str/lower email) - (:id profile) - (name role) expire - (name role) expire] - [sql:upsert-team-invitation id - (:id team) - (str/lower email) - (:id profile) - (name role) expire - (name role) expire])) - updated? (not= id (:id invitation)) - profile-id (:id profile) + (let [id (uuid/next) + expire (if organization + (ct/in-future "876000h") ;; Organization invitations doesn't expire + (ct/in-future "168h")) ;; 7 days + recent? (recently-invited? cfg (:id team) (:id organization) email) + invitation (db/exec-one! conn (if organization + [sql:upsert-organization-invitation id + (:id organization) + (str/lower email) + (:id profile) + (name role) expire + (name role) expire] + [sql:upsert-team-invitation id + (:id team) + (str/lower email) + (:id profile) + (name role) expire + (name role) expire])) + updated? (not= id (:id invitation)) + profile-id (:id profile) team-organization-id (get-in team [:organization :id]) - tprops {:profile-id profile-id - :invitation-id (:id invitation) - :valid-until expire - :team-id (:id team) - :organization-id (:id organization) - :organization-name (:name organization) - :member-email (:email-to invitation) - :member-id (:id member) - :role role} + tprops {:profile-id profile-id + :invitation-id (:id invitation) + :valid-until expire + :team-id (:id team) + :organization-id (:id organization) + :organization-name (:name organization) + :member-email (:email-to invitation) + :member-id (:id member) + :role role} audit-props (cond-> {:invitation-id (:id invitation) :valid-until expire @@ -222,9 +235,8 @@ :organization-name (:name organization) :member-email (:email-to invitation) :member-id (:id member) - :role role} - organization - (assoc :user-who-send-invitation (str profile-id)) + :role role + :user-who-send-invitation (str profile-id)} (not organization) (assoc :team-belongs-to-organization (boolean team-organization-id) @@ -234,8 +246,8 @@ (and team-organization-id member (contains? all-organization-member-ids (:id member)))))) - itoken (create-invitation-token cfg tprops) - ptoken (create-profile-identity-token cfg profile-id)] + itoken (create-invitation-token cfg tprops) + ptoken (create-profile-identity-token cfg profile-id)] (when (contains? cf/flags :log-invitation-tokens) (l/info :hint "invitation token" :token itoken)) @@ -251,7 +263,8 @@ (assoc :props props))] (audit/submit cfg event)) - (when (allow-invitation-emails? member) + (when (and (allow-invitation-emails? member) + (not recent?)) (if organization (when (contains? cf/flags :admin-console) (eml/send! {::eml/conn conn @@ -446,6 +459,10 @@ [cfg {:keys [::rpc/profile-id team-id role emails] :as params}] (let [perms (teams/get-permissions cfg profile-id team-id) profile (db/get-by-id cfg :profile profile-id) + team (db/get-by-id cfg :team team-id) + team-with-org (when (contains? cf/flags :admin-console) + (nitrate/add-organization-info-to-team cfg team {})) + organization (:organization team-with-org) ;; Determine which format is being used using-emails-format? (and emails role) ;; Handle both parameter formats @@ -461,6 +478,24 @@ (ex/raise :type :validation :code :insufficient-permissions)) + (when (and (contains? cf/flags :admin-console) + organization + (not (cto/allowed? :send-invitations + {:organization-perms {:owner-id (:owner-id organization) + :permissions (:permissions organization)} + :profile-id profile-id + :team-perms perms}))) + (ex/raise :type :validation + :code :insufficient-permissions + :hint "Organization policy does not allow you to send invitations")) + + ;; Don't allow promote to owner to admin users. + (when (and (not (:is-owner perms)) + (or (= role :owner) + (some #(= :owner (:role %)) (:invitations params)))) + (ex/raise :type :validation + :code :cant-promote-to-owner)) + (when (> invitation-count max-invitations-by-request-threshold) (ex/raise :type :validation :code :max-invitations-by-request @@ -567,7 +602,7 @@ ::doc/module :teams ::sm/params schema:get-team-invitation-token} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}] - (teams/check-read-permissions! cfg profile-id team-id) + (teams/check-edition-permissions! cfg profile-id team-id) (let [email (profile/clean-email email) invit (-> (db/get pool :team-invitation {:team-id team-id @@ -604,6 +639,11 @@ (ex/raise :type :validation :code :insufficient-permissions)) + ;; Don't allow promote to owner to admin users. + (when (and (not (:is-owner perms)) (= role :owner)) + (ex/raise :type :validation + :code :cant-promote-to-owner)) + (db/update! conn :team-invitation {:role (name role) :updated-at (ct/now)} {:team-id team-id :email-to (profile/clean-email email)}) diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index 8db7afcd8b..aafcb47096 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.verify-token (:require @@ -308,7 +308,9 @@ (assoc :name "accept-organization-invitation") (assoc :props (-> props - (assoc :organization-id organization-id-on-add) + (assoc :organization-id organization-id-on-add + :user-id (:id profile) + :user-who-send-invitation (:created-by invitation)) (audit/clean-props)))))) (cond-> (assoc claims :state :created) @@ -325,6 +327,8 @@ (assoc :organization-id organization-id-on-add :organization-member-add-source organization-add-source :belongs-to-team-on-add (boolean team-id) + :user-id (:id profile) + :user-who-send-invitation (:created-by invitation) :organization-member-count-before organization-member-count-before) (audit/clean-props))})))))) diff --git a/backend/src/app/rpc/commands/viewer.clj b/backend/src/app/rpc/commands/viewer.clj index 9333800af6..988cb26f5f 100644 --- a/backend/src/app/rpc/commands/viewer.clj +++ b/backend/src/app/rpc/commands/viewer.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.viewer (:require @@ -56,7 +56,7 @@ (assoc :can-read true))) (defn- get-view-only-bundle - [{:keys [::db/conn] :as cfg} {:keys [profile-id file-id ::perms] :as params}] + [{:keys [::db/conn] :as cfg} {:keys [profile-id file-id share-id ::perms] :as params}] (let [file (bfc/get-file cfg file-id) project (db/get conn :project @@ -89,16 +89,18 @@ (mapv (fn [{:keys [id] :as lib}] (merge lib (bfc/get-file cfg id))))) - links (->> (db/query conn :share-link {:file-id file-id}) - (mapv (fn [row] - (-> row - (update :pages db/decode-pgarray #{}) - ;; NOTE: the flags are deprecated but are still present - ;; on the table on old rows. The flags are pgarray and - ;; for avoid decoding it (because they are no longer used - ;; on frontend) we just dissoc the column attribute from - ;; row. - (dissoc :flags))))) + links (cond->> (->> (db/query conn :share-link {:file-id file-id}) + (mapv (fn [row] + (-> row + (update :pages db/decode-pgarray #{}) + ;; NOTE: the flags are deprecated but are still present + ;; on the table on old rows. The flags are pgarray and + ;; for avoid decoding it (because they are no longer used + ;; on frontend) we just dissoc the column attribute from + ;; row. + (dissoc :flags))))) + (= :share-link (:type perms)) + (filterv #(= (:id %) share-id))) fonts (db/query conn :team-font-variant {:team-id (:id team) diff --git a/backend/src/app/rpc/commands/webhooks.clj b/backend/src/app/rpc/commands/webhooks.clj index 33341bb34e..2476128e7c 100644 --- a/backend/src/app/rpc/commands/webhooks.clj +++ b/backend/src/app/rpc/commands/webhooks.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.webhooks (:require @@ -23,11 +23,9 @@ [cuerdas.core :as str])) (defn get-webhooks-permissions - [conn profile-id team-id creator-id] + [conn profile-id team-id] (let [permissions (t/get-permissions conn profile-id team-id) - - can-edit (boolean (or (:can-edit permissions) - (= profile-id creator-id)))] + can-edit (boolean (:can-edit permissions))] (assoc permissions :can-edit can-edit))) (def has-webhook-edit-permissions? @@ -120,7 +118,7 @@ {::doc/added "1.17" ::sm/params schema:create-webhook} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}] - (check-webhook-edition-permissions! pool profile-id team-id profile-id) + (t/check-edition-permissions! pool profile-id team-id) (validate-quotes! cfg params) (validate-webhook! cfg nil params) (insert-webhook! cfg params)) @@ -137,7 +135,7 @@ ::sm/params schema:update-webhook} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id] :as params}] (let [whook (-> (db/get pool :webhook {:id id}) (decode-row))] - (check-webhook-edition-permissions! pool profile-id (:team-id whook) (:profile-id whook)) + (check-webhook-edition-permissions! pool profile-id (:team-id whook)) (validate-webhook! cfg whook params) (update-webhook! cfg whook params))) @@ -151,7 +149,7 @@ ::db/transaction true} [{:keys [::db/conn]} {:keys [::rpc/profile-id id]}] (let [whook (-> (db/get conn :webhook {:id id}) decode-row)] - (check-webhook-edition-permissions! conn profile-id (:team-id whook) (:profile-id whook)) + (check-webhook-edition-permissions! conn profile-id (:team-id whook)) (db/delete! conn :webhook {:id id}) nil)) diff --git a/backend/src/app/rpc/cond.clj b/backend/src/app/rpc/cond.clj index 288bec4b9c..9837c5e92e 100644 --- a/backend/src/app/rpc/cond.clj +++ b/backend/src/app/rpc/cond.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.cond "Conditional loading middleware. diff --git a/backend/src/app/rpc/doc.clj b/backend/src/app/rpc/doc.clj index d5f73bf4de..205c01d975 100644 --- a/backend/src/app/rpc/doc.clj +++ b/backend/src/app/rpc/doc.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.doc "API autogenerated documentation." diff --git a/backend/src/app/rpc/helpers.clj b/backend/src/app/rpc/helpers.clj index 60c7e524cb..8ad1f841a4 100644 --- a/backend/src/app/rpc/helpers.clj +++ b/backend/src/app/rpc/helpers.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.helpers "General purpose RPC helpers." diff --git a/backend/src/app/rpc/management/exporter.clj b/backend/src/app/rpc/management/exporter.clj index 20e791e7d0..dabca00835 100644 --- a/backend/src/app/rpc/management/exporter.clj +++ b/backend/src/app/rpc/management/exporter.clj @@ -2,15 +2,16 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.management.exporter (:require + [app.common.media :as cm] [app.common.schema :as sm] [app.common.time :as ct] [app.common.uri :as u] [app.config :as cf] - [app.media :refer [schema:upload]] + [app.media.validation :as media.v] [app.rpc :as-alias rpc] [app.rpc.doc :as doc] [app.storage :as sto] @@ -21,7 +22,7 @@ (def ^:private schema:upload-tempfile-params [:map {:title "upload-templfile-params"} - [:content schema:upload]]) + [:content media.v/schema:upload]]) (def ^:private schema:upload-tempfile-result @@ -32,6 +33,7 @@ ::sm/params schema:upload-tempfile-params ::sm/result schema:upload-tempfile-result} [cfg {:keys [::rpc/profile-id content]}] + (media.v/validate-media-type! content cm/tempfile-types) (let [storage (sto/resolve cfg) hash (sto/calculate-hash (:path content)) data (-> (sto/content (:path content)) @@ -41,9 +43,10 @@ ::sto/touched-at (ct/in-future {:minutes 10}) :profile-id profile-id :content-type (:mtype content) - :bucket "tempfile"} + :bucket sto/tempfile-bucket} object (sto/put-object! storage content)] {:id (:id object) :uri (-> (cf/get :public-uri) - (u/join "/assets/by-id/") + (u/ensure-path-slash) + (u/join "assets/by-id/") (u/join (str (:id object))))})) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index e36b04fbb0..69d17ca701 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.management.nitrate "Internal Nitrate HTTP RPC API. Provides authenticated access to @@ -12,11 +12,13 @@ [app.auth.oidc :as oidc] [app.common.data :as d] [app.common.exceptions :as ex] + [app.common.media :as cm] [app.common.schema :as sm] [app.common.time :as ct] [app.common.types.organization :as cto] [app.common.types.profile :refer [schema:profile, schema:basic-profile]] [app.common.types.team :refer [schema:team]] + [app.common.uri :as u] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] @@ -24,7 +26,7 @@ [app.http :as-alias http] [app.http.session :as session] [app.loggers.audit :as audit] - [app.media :as media] + [app.media.validation :as media.v] [app.nitrate :as nitrate] [app.rpc :as rpc] [app.rpc.commands.auth :as auth] @@ -54,7 +56,7 @@ (sv/defmethod ::authenticate "Authenticate the current user" - {::doc/added "2.14" + {::doc/added "2.18" ::sm/params [:map] ::sm/result schema:profile ::nitrate/sso false} @@ -94,7 +96,7 @@ (sv/defmethod ::get-penpot-version "Get the current Penpot version" - {::doc/added "2.14" + {::doc/added "2.18" ::sm/params [:map] ::sm/result schema:get-penpot-version-result ::rpc/auth false} @@ -106,7 +108,7 @@ (sv/defmethod ::get-teams "List teams for which current user is owner" - {::doc/added "2.14" + {::doc/added "2.18" ::sm/params [:map] ::sm/result schema:get-teams-result ::nitrate/sso false} @@ -119,7 +121,7 @@ (def ^:private schema:upload-organization-logo [:map - [:content media/schema:upload] + [:content media.v/schema:upload] [:organization-id ::sm/uuid] [:previous-id {:optional true} ::sm/uuid]]) @@ -130,11 +132,12 @@ "Store an organization logo in penpot storage and return its ID. Accepts an optional previous-id to mark the old logo for garbage collection when replacing an existing one." - {::doc/added "2.17" + {::doc/added "2.18" ::sm/params schema:upload-organization-logo ::sm/result schema:upload-organization-logo-result ::nitrate/sso false} [{:keys [::sto/storage]} {:keys [content organization-id previous-id]}] + (media.v/validate-media-type! content cm/image-types) (when previous-id (sto/touch-object! storage previous-id)) (let [hash (sto/calculate-hash (:path content)) @@ -151,7 +154,7 @@ (sv/defmethod ::notify-team-change "Notify to Penpot a team change from nitrate" - {::doc/added "2.14" + {::doc/added "2.18" ::sm/params cto/schema:team-with-organization ::rpc/auth false} [cfg team] @@ -168,7 +171,7 @@ (sv/defmethod ::notify-user-added-to-organization "Notify to Penpot that an user has joined an organization from nitrate" - {::doc/added "2.14" + {::doc/added "2.18" ::sm/params schema:notify-user-added-to-organization ::rpc/auth false} [cfg {:keys [profile-id organization-id]}] @@ -199,7 +202,7 @@ (sv/defmethod ::get-managed-profiles "List profiles that belong to teams for which current user is owner" - {::doc/added "2.14" + {::doc/added "2.18" ::sm/params [:map] ::sm/result schema:managed-profile-result ::nitrate/sso false} @@ -239,7 +242,7 @@ (sv/defmethod ::get-teams-summary "Get summary information for a list of teams" - {::doc/added "2.15" + {::doc/added "2.18" ::sm/params schema:get-teams-summary-params ::sm/result schema:get-teams-summary-result ::nitrate/sso false} @@ -315,7 +318,7 @@ RETURNING id, deleted_at;") (defn manage-deleted-organization-teams "For a deleted organization, preserve organization teams unchanged and only prefix or - delete member Your Penpot teams depending on whether they still contain files." + delete member Personal Projects teams depending on whether they still contain files." [cfg {:keys [organization-id organization-name teams]}] (let [all-team-ids (->> teams (map :id) @@ -344,13 +347,13 @@ RETURNING id, deleted_at;") teams-to-delete (->> your-penpot-team-ids (remove teams-with-files) (into []))] ;; Organization teams move to the fallback organization unchanged. Only imported - ;; Your Penpot teams keep the organization prefix when they still have files. + ;; Personal Projects teams keep the organization prefix when they still have files. (when (seq teams-to-prefix) (db/exec! conn [sql:prefix-teams-name-and-unset-default organization-prefix (db/create-array conn "uuid" teams-to-prefix)])) - ;; Empty imported Your Penpot teams disappear entirely. + ;; Empty imported Personal Projects teams disappear entirely. (soft-delete-teams! cfg teams-to-delete) (notifications/notify-organization-deletion cfg organization-id organization-name all-team-ids teams-to-delete) @@ -359,8 +362,8 @@ RETURNING id, deleted_at;") (sv/defmethod ::notify-organization-deletion "For a deleted organization, preserve organization teams and only prefix or delete - imported Your Penpot teams before notifying connected users." - {::doc/added "2.15" + imported Personal Projects before notifying connected users." + {::doc/added "2.18" ::sm/params schema:notify-organization-deletion ::rpc/auth false} [cfg {:keys [organization-id]}] @@ -379,7 +382,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::notify-user-organizations-deletion "For a given user, find all owned organizations and apply the deleted-organization - transfer rules to their imported Your Penpot teams." + transfer rules to their imported Personal Projects teams." {::doc/added "2.18" ::sm/params schema:notify-user-organizations-deletion ::nitrate/sso false} @@ -406,7 +409,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::get-profile-by-email "Get profile by email" - {::doc/added "2.15" + {::doc/added "2.18" ::sm/params [:map [:email ::sm/email]] ::sm/result schema:profile ::nitrate/sso false} @@ -430,7 +433,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::get-profile-by-id "Get profile by email" - {::doc/added "2.15" + {::doc/added "2.18" ::sm/params [:map [:id ::sm/uuid]] ::sm/result schema:profile ::nitrate/sso false} @@ -465,7 +468,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::get-organization-member-team-counts "Get the number of non-default teams each profile belongs to within a set of teams." - {::doc/added "2.15" + {::doc/added "2.18" ::sm/params schema:get-organization-member-team-counts-params ::sm/result schema:get-organization-member-team-counts-result ::rpc/auth false} @@ -488,15 +491,33 @@ RETURNING id, deleted_at;") ;; API: invite-to-organization +(defn- get-invitation-organization + [cfg profile-id organization-id] + (let [{:keys [id name owner-id logo-id avatar-bg-url sso-active]} + (nitrate/call cfg :get-organization-summary {:organization-id organization-id})] + (when-not (= profile-id owner-id) + (ex/raise :type :not-found + :code :object-not-found + :hint "not found")) + {:id id + :name name + :initials (if logo-id "" (d/get-initials name)) + :logo (when logo-id (u/uri (files/resolve-public-uri logo-id))) + :avatar-bg-url (when-not logo-id avatar-bg-url) + :sso-active (true? sso-active)})) + (sv/defmethod ::invite-to-organization "Invite to organization" - {::doc/added "2.15" + {::doc/added "2.18" ::sm/params [:map [:email ::sm/email] [:organization cto/schema:organization-with-avatar]] ::nitrate/sso false} - [cfg params] - (db/tx-run! cfg ti/create-organization-invitation params) + [cfg {profile-id ::rpc/profile-id + :keys [organization] + :as params}] + (let [organization (get-invitation-organization cfg profile-id (:id organization))] + (db/tx-run! cfg ti/create-organization-invitation (assoc params :organization organization))) nil) @@ -519,7 +540,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::get-organization-invitations "Get valid invitations for an organization, returning at most one invitation per email." - {::doc/added "2.16" + {::doc/added "2.18" ::sm/params schema:get-organization-invitations-params ::sm/result schema:get-organization-invitations-result ::nitrate/sso false} @@ -547,7 +568,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::delete-organization-invitations "Delete all invitations for one email in an organization scope (organization + organization teams)." - {::doc/added "2.16" + {::doc/added "2.18" ::sm/params schema:delete-organization-invitations-params ::nitrate/sso false} [cfg {:keys [organization-id email]}] @@ -612,7 +633,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::remove-from-organization "Remove an user from an organization" - {::doc/added "2.17" + {::doc/added "2.18" ::sm/params [:map [:profile-id ::sm/uuid] [:organization-id ::sm/uuid] @@ -657,7 +678,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::get-remove-from-organization-summary "Get a summary of the teams that would be deleted, transferred, or exited if the user were removed from the organization" - {::doc/added "2.17" + {::doc/added "2.18" ::sm/params [:map [:profile-id ::sm/uuid] [:organization-id ::sm/uuid] @@ -692,7 +713,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::send-renewal-email "Send an Enterprise subscription renewal notice email to a user." - {::doc/added "2.17" + {::doc/added "2.18" ::sm/params schema:send-renewal-email-params ::rpc/auth false} [cfg {:keys [profile-id user-email user-name renewal-date estimated-amount organizations]}] @@ -805,7 +826,7 @@ RETURNING id, deleted_at;") "Push audit events from nitrate (strictly for nitrate backend events)" - {::doc/added "2.19" + {::doc/added "2.18" ::audit/skip true ::sm/params schema:push-audit-events-params ::rpc/auth false} @@ -912,7 +933,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::get-teams-detail "Get detailed information for all non-deleted teams in an organization, including owner info and project/file/member counts." - {::doc/added "2.20" + {::doc/added "2.18" ::sm/params schema:get-teams-detail-params ::sm/result schema:get-teams-detail-result ::nitrate/sso false} @@ -940,7 +961,7 @@ RETURNING id, deleted_at;") "Validate an organization SSO configuration by generating a login redirect URL. Nitrate calls this while configuring SSO to verify client credentials and OIDC discovery before saving the settings." - {::doc/added "2.20" + {::doc/added "2.18" ::sm/params cto/schema:nitrate-sso ::sm/result schema:check-organization-sso-result ::rpc/auth false} @@ -950,7 +971,7 @@ RETURNING id, deleted_at;") ;; ---- API: notify-organization-sso-change (sv/defmethod ::notify-organization-sso-change "Nitrate notifies that an organization sso values have changed" - {::doc/added "2.19" + {::doc/added "2.18" ::sm/params [:map [:organization-id ::sm/uuid] [:updated-props ::sm/boolean] @@ -999,7 +1020,7 @@ RETURNING id, deleted_at;") bulk-creation screen; access is gated by the shared key and, in Nitrate, an email allow-list. Requires the `admin-console-bulk-create-profiles` flag, disabled by default so it is only available on test environments." - {::doc/added "2.19" + {::doc/added "2.18" ::sm/params schema:bulk-create-profiles-params ::sm/result schema:bulk-create-profiles-result ::rpc/auth false} @@ -1024,3 +1045,18 @@ RETURNING id, deleted_at;") (update acc :created conj email))))) {:created [] :skipped []} emails))))) + +;; ---- API: get-air-gapped + +(def ^:private schema:get-air-gapped-result + [:map + [:air-gapped ::sm/boolean]]) + +(sv/defmethod ::get-air-gapped + "Returns whether this Penpot instance runs in air-gapped mode." + {::doc/added "2.18" + ::sm/params [:map] + ::sm/result schema:get-air-gapped-result + ::rpc/auth false} + [_cfg _params] + {:air-gapped (contains? cf/flags :air-gapped-conf)}) diff --git a/backend/src/app/rpc/nitrate/emails_helper.clj b/backend/src/app/rpc/nitrate/emails_helper.clj index 73d72c178c..5a9379e358 100644 --- a/backend/src/app/rpc/nitrate/emails_helper.clj +++ b/backend/src/app/rpc/nitrate/emails_helper.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.nitrate.emails-helper "Helpers for organization SSO notification emails triggered by Nitrate integration." diff --git a/backend/src/app/rpc/nitrate/organization_helper.clj b/backend/src/app/rpc/nitrate/organization_helper.clj index ed5d918e8b..e5cabf4c72 100644 --- a/backend/src/app/rpc/nitrate/organization_helper.clj +++ b/backend/src/app/rpc/nitrate/organization_helper.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.nitrate.organization-helper "Shared Nitrate organization query helpers." diff --git a/backend/src/app/rpc/notifications.clj b/backend/src/app/rpc/notifications.clj index ec3d7c89d9..ad68556493 100644 --- a/backend/src/app/rpc/notifications.clj +++ b/backend/src/app/rpc/notifications.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.notifications (:require diff --git a/backend/src/app/rpc/permissions.clj b/backend/src/app/rpc/permissions.clj index 36ff9b2c23..77653017db 100644 --- a/backend/src/app/rpc/permissions.clj +++ b/backend/src/app/rpc/permissions.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.permissions "A permission checking helper factories." diff --git a/backend/src/app/rpc/quotes.clj b/backend/src/app/rpc/quotes.clj index 0a7004cc54..119f2a4b26 100644 --- a/backend/src/app/rpc/quotes.clj +++ b/backend/src/app/rpc/quotes.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.quotes "Penpot resource usage quotes." @@ -546,6 +546,76 @@ (assoc ::count-sql [sql:get-upload-sessions-per-profile profile-id]) (generic-check!))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; QUOTE: MEDIA-STORAGE-BYTES-PER-TEAM +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private schema:media-storage-bytes-per-team + [:map + [::profile-id ::sm/uuid] + [::team-id ::sm/uuid]]) + +(def ^:private valid-media-storage-bytes-per-team-quote? + (sm/lazy-validator schema:media-storage-bytes-per-team)) + +(def ^:private sql:get-media-storage-bytes-per-team + "SELECT COALESCE(SUM(so.size), 0) AS total + FROM ( + SELECT fmo.media_id AS so_id + FROM file_media_object AS fmo + JOIN file AS f ON (f.id = fmo.file_id) + JOIN project AS p ON (p.id = f.project_id) + WHERE p.team_id = ? + AND fmo.deleted_at IS NULL + AND f.deleted_at IS NULL + UNION + SELECT fmo.thumbnail_id AS so_id + FROM file_media_object AS fmo + JOIN file AS f ON (f.id = fmo.file_id) + JOIN project AS p ON (p.id = f.project_id) + WHERE p.team_id = ? + AND fmo.thumbnail_id IS NOT NULL + AND fmo.deleted_at IS NULL + AND f.deleted_at IS NULL + UNION + SELECT v.otf_file_id AS so_id + FROM team_font_variant AS v + WHERE v.team_id = ? + AND v.otf_file_id IS NOT NULL + AND v.deleted_at IS NULL + UNION + SELECT v.ttf_file_id AS so_id + FROM team_font_variant AS v + WHERE v.team_id = ? + AND v.ttf_file_id IS NOT NULL + AND v.deleted_at IS NULL + UNION + SELECT v.woff1_file_id AS so_id + FROM team_font_variant AS v + WHERE v.team_id = ? + AND v.woff1_file_id IS NOT NULL + AND v.deleted_at IS NULL + UNION + SELECT v.woff2_file_id AS so_id + FROM team_font_variant AS v + WHERE v.team_id = ? + AND v.woff2_file_id IS NOT NULL + AND v.deleted_at IS NULL + ) AS refs + JOIN storage_object AS so ON (so.id = refs.so_id) + WHERE so.deleted_at IS NULL") + +(defmethod check-quote ::media-storage-bytes-per-team + [{:keys [::profile-id ::team-id ::target] :as quote}] + (assert (valid-media-storage-bytes-per-team-quote? quote) "invalid quote parameters") + (-> quote + (assoc ::default (cf/get :quotes-media-storage-bytes-per-team + (* 20 1024 1024 1024))) + (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id]) + (assoc ::count-sql [sql:get-media-storage-bytes-per-team + team-id team-id team-id team-id team-id team-id]) + (generic-check!))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: DEFAULT ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/backend/src/app/rpc/retry.clj b/backend/src/app/rpc/retry.clj index 7bc52f1649..7665eabbed 100644 --- a/backend/src/app/rpc/retry.clj +++ b/backend/src/app/rpc/retry.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.retry (:require diff --git a/backend/src/app/rpc/rlimit.clj b/backend/src/app/rpc/rlimit.clj index 8c28f6a3c6..d78897e50b 100644 --- a/backend/src/app/rpc/rlimit.clj +++ b/backend/src/app/rpc/rlimit.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.rlimit "Rate limit strategies implementation for RPC services. @@ -46,6 +46,7 @@ [app.common.data :as d] [app.common.exceptions :as ex] [app.common.logging :as l] + [app.common.math :as mth] [app.common.schema :as sm] [app.common.time :as ct] [app.common.uri :as uri] @@ -180,8 +181,8 @@ result (rds/eval rconn script) allowed? (boolean (nth result 0)) remaining (nth result 1) - reset (* (/ (inst-ms interval) rate) - (- capacity remaining))] + reset (long (mth/ceil (double (* (/ (inst-ms interval) rate) + (- capacity remaining)))))] (l/trace :hint "limit processed" :method method :limit (name (::name limit)) @@ -190,6 +191,7 @@ :allowed allowed? :remaining remaining) (-> limit + (assoc ::lresult/now now) (assoc ::lresult/allowed allowed?) (assoc ::lresult/reset (ct/plus now reset)) (assoc ::lresult/remaining remaining)))) @@ -212,6 +214,7 @@ :allowed allowed? :remaining remaining) (-> limit + (assoc ::lresult/now now) (assoc ::lresult/allowed allowed?) (assoc ::lresult/timestamp ts) (assoc ::lresult/remaining remaining) diff --git a/backend/src/app/setup.clj b/backend/src/app/setup.clj index ed3a3364f0..2afb5955b4 100644 --- a/backend/src/app/setup.clj +++ b/backend/src/app/setup.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.setup "Initial data setup of instance." @@ -116,7 +116,8 @@ {} [:exporter :admin-console - :nexus]))) + :nexus + :media-processor]))) (sm/register! ::props [:map-of :keyword ::sm/any]) (sm/register! ::shared-keys [:map-of :keyword ::sm/text]) diff --git a/backend/src/app/setup/clock.clj b/backend/src/app/setup/clock.clj index 22f04831e5..4b8644019e 100644 --- a/backend/src/app/setup/clock.clj +++ b/backend/src/app/setup/clock.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.setup.clock "A service/module that manages the system clock and allows runtime diff --git a/backend/src/app/setup/keys.clj b/backend/src/app/setup/keys.clj index 25fdc9a854..aac9d60bd7 100644 --- a/backend/src/app/setup/keys.clj +++ b/backend/src/app/setup/keys.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.setup.keys "Keys derivation service." diff --git a/backend/src/app/setup/templates.clj b/backend/src/app/setup/templates.clj index c90120d22f..4cb45e31c1 100644 --- a/backend/src/app/setup/templates.clj +++ b/backend/src/app/setup/templates.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.setup.templates "A service/module that is responsible for download, load & internally diff --git a/backend/src/app/setup/welcome_file.clj b/backend/src/app/setup/welcome_file.clj index 48887e53ed..f6874aafa5 100644 --- a/backend/src/app/setup/welcome_file.clj +++ b/backend/src/app/setup/welcome_file.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.setup.welcome-file (:require diff --git a/backend/src/app/srepl.clj b/backend/src/app/srepl.clj index 8faa741ab0..2f6935b226 100644 --- a/backend/src/app/srepl.clj +++ b/backend/src/app/srepl.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.srepl "Server Repl." diff --git a/backend/src/app/srepl/binfile.clj b/backend/src/app/srepl/binfile.clj index badf02d98c..43cb69f8f3 100644 --- a/backend/src/app/srepl/binfile.clj +++ b/backend/src/app/srepl/binfile.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.srepl.binfile (:require diff --git a/backend/src/app/srepl/cli.clj b/backend/src/app/srepl/cli.clj index dc44047e32..dda60ca054 100644 --- a/backend/src/app/srepl/cli.clj +++ b/backend/src/app/srepl/cli.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.srepl.cli "PREPL API for external usage (CLI or ADMIN)" @@ -232,7 +232,7 @@ [:enum "customer_service" "low_quality" - "missing_feature" + "missing_features" "other" "switched_service" "too_complex" diff --git a/backend/src/app/srepl/helpers.clj b/backend/src/app/srepl/helpers.clj index 658181635b..18221f9434 100644 --- a/backend/src/app/srepl/helpers.clj +++ b/backend/src/app/srepl/helpers.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.srepl.helpers "A main namespace for server repl." @@ -153,7 +153,7 @@ (defn process-file! [system file-id update-fn - & {:keys [::snapshot-label ::validate? ::with-libraries?] + & {:keys [::profile-id ::snapshot-label ::validate? ::with-libraries?] :or {validate? true} :as opts}] (let [file (bfc/get-file system file-id :lock-for-update? true @@ -177,8 +177,9 @@ (when (string? snapshot-label) (fsnap/create! system file {:label snapshot-label + :profile-id profile-id :deleted-at (ct/in-future {:days 30}) - :created-by "admin"})) + :created-by "system"})) (let [file' (update file' :revn inc)] (bfc/update-file! system file' opts) diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj index 6745858f0e..4014ad8dec 100644 --- a/backend/src/app/srepl/main.clj +++ b/backend/src/app/srepl/main.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.srepl.main #_:clj-kondo/ignore @@ -399,9 +399,52 @@ (ex/print-throwable cause)))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; PROCESSING +;; GRAPH / LADYBUG ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; The graph namespaces resolve at call time, never at the top of this +;; namespace. `app.graph.ladybug` imports `com.ladybugdb.*`, and this namespace +;; loads with the REPL server on every boot, so a top-level require would link +;; the Ladybug native library into every backend, graph or not. Calling one of +;; the functions below loads the library at that point: the operator has asked +;; for it explicitly. The `:graph` flag gates the request path +;; (`app.http.debug`), not the REPL. + +(defn graph-smoke-test! + "Execute a basic Ladybug smoke test (CREATE + count). + + Uses the embedded Ladybug Java API. Use :db-path \":memory:\" (default) + or a filesystem path such as /tmp/test.lbug." + [& {:keys [db-path] :or {db-path ":memory:"}}] + ((requiring-resolve 'app.graph.ladybug/smoke-test!) :db-path db-path)) + +(defn graph-query-test! + "Query Document count for a file's graph db (REPL diagnostic)." + [file-id & {:keys [db-path]}] + (let [file-id (h/parse-uuid file-id) + db-path (or db-path ((requiring-resolve 'app.graph.ladybug/db-path-for-file) file-id)) + query-scalar! (requiring-resolve 'app.graph.ladybug/query-scalar!) + stmt "MATCH (n:Document) RETURN count(n) AS Document_c;"] + (query-scalar! db-path stmt))) + +(defn ingest-file-to-graph! + "Project a Penpot file into a per-file Ladybug database. + + Loads and realizes the file from the database, ensures the slice schema, + projects Document/Page/shape nodes, and returns graph stats. + + Options: + - `:db-path` path or `:memory:` + - `:reset-db?` delete any existing db first (default true) + - `:skip-stats?` skip post-ingest MATCH count queries (default false)" + [file-id & opts] + (let [ingest-file! (requiring-resolve 'app.graph.ingest/ingest-file!) + print-ingest! (requiring-resolve 'app.graph.report/print-ingest!) + result (apply ingest-file! sys/system file-id opts)] + (print-ingest! result) + result)) + + (defn repair-file! "Repair the list of errors detected by validation." [file-id & {:keys [rollback?] :or {rollback? true} :as options}] @@ -410,6 +453,10 @@ options (assoc options ::h/with-libraries? true)] (db/tx-run! system h/process-file! file-id procs.file-repair/repair-file options))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PROCESSING +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + (defn update-file! "Apply a function to the file. Optionally save the changes or not. The function receives the decoded and migrated file data." diff --git a/backend/src/app/srepl/procs/fdata_storage.clj b/backend/src/app/srepl/procs/fdata_storage.clj index 5ed64e206e..5759aa43b9 100644 --- a/backend/src/app/srepl/procs/fdata_storage.clj +++ b/backend/src/app/srepl/procs/fdata_storage.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.srepl.procs.fdata-storage (:require diff --git a/backend/src/app/srepl/procs/file_repair.clj b/backend/src/app/srepl/procs/file_repair.clj index fba086433a..f863f250cf 100644 --- a/backend/src/app/srepl/procs/file_repair.clj +++ b/backend/src/app/srepl/procs/file_repair.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.srepl.procs.file-repair (:require diff --git a/backend/src/app/srepl/procs/media_refs.clj b/backend/src/app/srepl/procs/media_refs.clj index b1a492e226..fc6d0721d7 100644 --- a/backend/src/app/srepl/procs/media_refs.clj +++ b/backend/src/app/srepl/procs/media_refs.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.srepl.procs.media-refs (:require diff --git a/backend/src/app/srepl/procs/path_data.clj b/backend/src/app/srepl/procs/path_data.clj index a26ab288df..545cc2ff47 100644 --- a/backend/src/app/srepl/procs/path_data.clj +++ b/backend/src/app/srepl/procs/path_data.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.srepl.procs.path-data (:require diff --git a/backend/src/app/storage.clj b/backend/src/app/storage.clj index f30d8762ec..0f35b0a54a 100644 --- a/backend/src/app/storage.clj +++ b/backend/src/app/storage.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.storage "Objects storage abstraction layer." @@ -38,6 +38,10 @@ (def default-bucket "file-media-object") +(def tempfile-bucket + "Bucket name for temporary file uploads (10-minute expiry)." + "tempfile") + (def valid-buckets #{"file-media-object" "team-font-variant" @@ -45,7 +49,7 @@ "file-thumbnail" "profile" "organization" - "tempfile" + tempfile-bucket "file-data" "file-data-fragment" "file-change"}) @@ -66,7 +70,7 @@ [:map {:title "storage"} [::backends schema:backends] [::backend [:enum :s3 :fs]] - ::db/connectable]) + ::db/pool]) (def valid-storage? (sm/validator schema:storage)) @@ -92,7 +96,7 @@ (-> (d/without-nils cfg) (assoc ::backends backends) (assoc ::backend backend) - (assoc ::db/connectable pool)))) + (assoc ::db/pool pool)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Database Objects @@ -114,60 +118,26 @@ " and (metadata->>'~:bucket') = ? " " and backend = ?" " and deleted_at is null" + " and status = 'valid'" " limit 1")] - (some-> (db/exec-one! connectable [sql hash bucket (name backend)]) - (update :metadata db/decode-transit-pgobject)))) + ;; NOTE: metadata is left encoded; row->storage-object is + ;; responsible for decoding it. + (db/exec-one! connectable [sql hash bucket (name backend)]))) -(defn- create-database-object - [{:keys [::backend ::db/connectable]} {:keys [::content ::expired-at ::touched-at ::touch] :as params}] - (let [id (or (::id params) (uuid/random)) - mdata (cond-> (get-metadata params) - (satisfies? impl/IContentHash content) - (assoc :hash (impl/get-hash content))) - - touched-at (if touch - (or touched-at (ct/now)) - touched-at) - - ;; NOTE: for now we don't reuse the deleted objects, but in - ;; futute we can consider reusing deleted objects if we - ;; found a duplicated one and is marked for deletion but - ;; still not deleted. - result (when (and (::deduplicate? params) - (:hash mdata) - (:bucket mdata) - (not= "tempfile" (:bucket mdata))) - (let [result (get-database-object-by-hash connectable backend - (:bucket mdata) - (:hash mdata))] - (if touch - (do - (db/update! connectable :storage-object - {:touched-at touched-at} - {:id (:id result)} - {::db/return-keys false}) - (assoc result :touced-at touched-at)) - result))) - - result (or result - (-> (db/insert! connectable :storage-object - {:id id - :size (impl/get-size content) - :backend (name backend) - :metadata (db/tjson mdata) - :deleted-at expired-at - :touched-at touched-at}) - (update :metadata db/decode-transit-pgobject) - (update :metadata assoc ::created? true)))] - - (impl/storage-object - (:id result) - (:size result) - (:created-at result) - (:deleted-at result) - (:touched-at result) - backend - (:metadata result)))) +(defn- promote-object! + [storage object] + (let [ds (db/get-connectable storage) + res (-> (db/update! ds :storage-object + {:status "valid"} + {:id (:id object)} + {::db/return-keys false}) + (db/get-update-count))] + (when-not (pos? res) + ;; The pending row disappeared while the blob was being written + ;; (e.g. reclaimed by :storage-pending-gc); make it observable. + (l/wrn :hint "unable to promote storage object, pending row not found" + :id (str (:id object)))) + res)) (defn row->storage-object [res] (let [mdata (or (some-> (:metadata res) (db/decode-transit-pgobject)) {})] @@ -184,7 +154,8 @@ "SELECT * FROM storage_object WHERE id = ? - AND (deleted_at IS NULL)") + AND (deleted_at IS NULL) + AND status = 'valid'") (defn- get-database-object [conn id] @@ -209,29 +180,93 @@ (dm/export impl/object?) (defn get-object - [{:keys [::db/connectable] :as storage} id] + [storage id] (assert (valid-storage? storage)) - (get-database-object connectable id)) + (let [ds (db/get-connectable storage)] + (get-database-object ds id))) (defn put-object! "Creates a new object with the provided content." - [{:keys [::backend] :as storage} {:keys [::content] :as params}] + [{:keys [::backend ::db/pool] :as storage} + {:keys [::content ::expired-at ::touched-at ::touch] :as params}] (assert (valid-storage? storage)) (assert (impl/content? content) "expected an instance of content") - (let [object (create-database-object storage params)] - (if (::created? (meta object)) - ;; Store the data finally on the underlying storage subsystem. - (-> (impl/resolve-backend storage backend) - (impl/put-object object content)) - object))) + (let [id (or (::id params) (uuid/random)) + mdata (cond-> (get-metadata params) + (satisfies? impl/IContentHash content) + (assoc :hash (impl/get-hash content))) + + touched-at (if touch + (or touched-at (ct/now)) + touched-at) + + backend' (impl/resolve-backend storage backend)] + + ;; NOTE: for now we don't reuse the deleted objects, but in futute + ;; we can consider reusing deleted objects if we found a duplicated + ;; one and is marked for deletion but still not deleted. + + ;; PHASE 1: deduplication lookup. + (if-some [hit (when (and (::deduplicate? params) + (:hash mdata) + (:bucket mdata) + (not= tempfile-bucket (:bucket mdata))) + (get-database-object-by-hash pool backend + (:bucket mdata) + (:hash mdata)))] + + ;; PHASE 2: an existing reference is found: reuse or repair it. + (if (impl/exists-object? backend' hit) + + ;; PHASE 2a: healthy reference. Optionally refresh touched_at + ;; and reuse the object as it is. + (do + (when touch + (db/update! pool :storage-object + {:touched-at touched-at} + {:id (:id hit)} + {::db/return-keys false})) + (row->storage-object (cond-> hit touch (assoc :touched-at touched-at)))) + + ;; PHASE 2b: the referenced blob is missing (a stale/broken row). + ;; Repair the reference in place: rewrite the incoming content + ;; under the same id, restoring the blob for all existing + ;; references to it. If the write fails, the exception propagates + ;; and the row stays live and valid, so a later matching upload + ;; retries the heal. + (let [object (row->storage-object hit)] + (l/wrn :hint "blob not found on reusing storage object" + :id (:id object) + :backend (name backend)) + (impl/put-object backend' object content) + (promote-object! storage object) + object)) + + ;; PHASE 3: no dedup hit: create a fresh object. The row is + ;; inserted in 'pending' state so it is not visible to the normal + ;; lifecycle (dedup, gc, reads) until the blob has been written + ;; and the object promoted to 'valid'. + (let [row (db/insert! pool :storage-object + {:id id + :size (impl/get-size content) + :backend (name backend) + :metadata (db/tjson mdata) + :deleted-at expired-at + :touched-at touched-at + :status "pending"}) + object (row->storage-object row)] + (impl/put-object backend' object content) + (promote-object! storage object) + object)))) (defn touch-object! "Mark object as touched." - [{:keys [::db/connectable] :as storage} object-or-id] + [storage object-or-id] (assert (valid-storage? storage)) - (let [id (if (impl/object? object-or-id) (:id object-or-id) object-or-id)] - (-> (db/update! connectable :storage-object + (let [id (if (impl/object? object-or-id) (:id object-or-id) object-or-id) + ds (db/get-connectable storage)] + (-> (db/update! ds :storage-object {:touched-at (ct/now)} {:id id}) (db/get-update-count) @@ -278,10 +313,11 @@ (-> (impl/get-object-url backend object nil) file-url->path)))) (defn del-object! - [{:keys [::db/connectable] :as storage} object-or-id] + [storage object-or-id] (assert (valid-storage? storage)) (let [id (if (impl/object? object-or-id) (:id object-or-id) object-or-id) - res (db/update! connectable :storage-object + ds (db/get-connectable storage) + res (db/update! ds :storage-object {:deleted-at (ct/now)} {:id id})] (pos? (db/get-update-count res)))) @@ -291,9 +327,10 @@ (dm/export impl/get-size) (defn configure - [storage connectable] + [storage connection] + (assert (db/connection? connection)) (assert (valid-storage? storage)) - (assoc storage ::db/connectable connectable)) + (assoc storage ::db/conn connection)) (defn resolve "Resolves the storage instance with preconfigured backend. You can @@ -302,5 +339,5 @@ [cfg & {:as opts}] (let [storage (::storage cfg)] (if (::db/reuse-conn opts false) - (configure storage (db/get-connectable cfg)) + (configure storage (db/get-connection cfg)) storage))) diff --git a/backend/src/app/storage/fs.clj b/backend/src/app/storage/fs.clj index bbeb3010c7..cf07128579 100644 --- a/backend/src/app/storage/fs.clj +++ b/backend/src/app/storage/fs.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.storage.fs (:require @@ -11,6 +11,7 @@ [app.common.uri :as u] [app.storage :as-alias sto] [app.storage.impl :as impl] + [app.storage.tmp :as tmp] [cuerdas.core :as str] [datoteka.fs :as fs] [datoteka.io :as io] @@ -18,8 +19,11 @@ (:import java.io.InputStream java.io.OutputStream + java.nio.file.AtomicMoveNotSupportedException + java.nio.file.CopyOption java.nio.file.Files - java.nio.file.Path)) + java.nio.file.Path + java.nio.file.StandardCopyOption)) (set! *warn-on-reflection* true) @@ -59,17 +63,49 @@ (assert (valid-backend? backend) "expected a valid backend instance") (let [base (fs/path (::directory backend)) path (fs/path (impl/id->path id)) - full (fs/normalize (fs/join base path))] + full (fs/normalize (fs/join base path)) + parent-dir (fs/parent full)] - (when-not (fs/exists? (fs/parent full)) - (fs/create-dir (fs/parent full))) + (when-not (fs/exists? parent-dir) + (fs/create-dir parent-dir)) - (with-open [^InputStream src (io/input-stream content)] - (with-open [^OutputStream dst (io/output-stream full)] - (io/copy src dst))) + ;; Create temp file in the same directory (same filesystem → atomic + ;; move preserved) and register with cleanup queue (crashed-JVM files + ;; are swept ~60min later). + (let [tmp (tmp/tempfile :dir (str parent-dir) + :prefix (str (fs/name full) ".") + :suffix ".tmp" + :min-age "1h")] + + ;; Write to a temporary file in the same directory and atomically move + ;; it into place, so a failed write never leaves a partial blob at the + ;; final path. + (try + (with-open [^InputStream src (io/input-stream content)] + (with-open [^OutputStream dst (io/output-stream tmp)] + (io/copy src dst))) + ;; ATOMIC_MOVE is POSIX-only; on non-POSIX filesystems (e.g. Windows) + ;; this may throw FileAlreadyExistsException if the target exists. + (try + (Files/move ^Path tmp ^Path full + (into-array CopyOption [StandardCopyOption/ATOMIC_MOVE])) + (catch AtomicMoveNotSupportedException _ + (Files/move ^Path tmp ^Path full + (into-array CopyOption [StandardCopyOption/REPLACE_EXISTING])))) + (catch Throwable cause + ;; Temp file cleanup is handled by the cleanup queue; just rethrow. + (throw cause)))) object)) +(defmethod impl/exists-object? :fs + [backend {:keys [id]}] + (assert (valid-backend? backend) "expected a valid backend instance") + (let [^Path base (fs/path (::directory backend)) + ^Path path (fs/path (impl/id->path id)) + ^Path full (fs/normalize (fs/join base path))] + (fs/exists? full))) + (defmethod impl/get-object-data :fs [backend {:keys [id] :as object}] (assert (valid-backend? backend) "expected a valid backend instance") @@ -108,8 +144,12 @@ [backend ids] (assert (valid-backend? backend) "expected a valid backend instance") (let [base (fs/path (::directory backend))] - (doseq [id ids] - (let [path (fs/path (impl/id->path id)) - path (fs/join base path)] - (Files/deleteIfExists ^Path path))))) + (reduce (fn [fail-ids id] + (let [path (fs/normalize (fs/join base (fs/path (impl/id->path id))))] + (try + (Files/deleteIfExists ^Path path) + fail-ids + (catch Throwable _ + (conj fail-ids id))))) + #{} ids))) diff --git a/backend/src/app/storage/gc_deleted.clj b/backend/src/app/storage/gc_deleted.clj index c380293453..ab49030041 100644 --- a/backend/src/app/storage/gc_deleted.clj +++ b/backend/src/app/storage/gc_deleted.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.storage.gc-deleted "A task responsible to permanently delete already marked as deleted @@ -19,8 +19,18 @@ [app.db :as db] [app.storage :as sto] [app.storage.impl :as impl] + [clojure.set :as set] [integrant.core :as ig])) +(def ^:private max-attempts + "Maximum number of deletion attempts before giving up and accepting + the orphan blob." + 7) + +(def ^:private chunk-size + "Number of rows to process per transaction." + 25) + (def ^:private sql:lock-sobjects "SELECT id FROM storage_object WHERE id = ANY(?::uuid[]) @@ -47,66 +57,110 @@ (-> (db/exec-one! conn [sql:delete-sobjects ids]) (db/get-update-count)))) -(defn- delete-in-bulk! - [cfg backend-id ids] - ;; We run the deletion on a separate transaction. This is - ;; because if some exception is raised inside procesing - ;; one chunk, it does not affects the rest of the chunks. - (try - (db/tx-run! cfg - (fn [{:keys [::db/conn ::sto/storage]}] - (when-let [ids (lock-ids conn ids)] - (let [total (delete-sobjects! conn ids)] - (-> (impl/resolve-backend storage backend-id) - (impl/del-objects-in-bulk ids)) +(def ^:private sql:increment-attempts-and-defer + "UPDATE storage_object + SET deletion_attempts = deletion_attempts + 1, + deleted_at = NOW() + INTERVAL '1 day' + WHERE id = ANY(?::uuid[])") - (doseq [id ids] - (l/dbg :hint "permanently delete storage object" - :id (str id) - :backend (name backend-id))) - total)))) - (catch Throwable cause - (l/err :hint "unexpected error on bulk deletion" - :ids ids - :cause cause)))) +(defn- increment-attempts-and-defer! + [conn ids] + (let [ids (db/create-array conn "uuid" ids)] + (db/exec-one! conn [sql:increment-attempts-and-defer ids]))) +(def ^:private sql:delete-give-up + "DELETE FROM storage_object + WHERE id = ANY(?::uuid[]) + AND deletion_attempts >= ?") + +(defn- delete-give-up! + [conn ids] + (let [ids (db/create-array conn "uuid" ids)] + (db/exec-one! conn [sql:delete-give-up ids max-attempts]))) + +(defn- process-chunk + "Attempt to delete a chunk of storage objects from a specific backend. + + This function runs inside the caller's transaction (clean-deleted!) — + it does NOT open its own transaction. The caller is responsible for + ensuring the rows are locked via FOR UPDATE SKIP LOCKED before calling. + + Returns the number of successfully deleted objects, or 0 if no rows + could be locked." + [conn storage backend-id ids] + (if-let [locked-ids (lock-ids conn ids)] + (let [fail-ids (try + (-> (impl/resolve-backend storage backend-id) + (impl/del-objects-in-bulk locked-ids)) + (catch Throwable cause + (l/err :hint "error on physical deletion, will retry" + :ids locked-ids + :cause cause) + locked-ids)) + ok-ids (set/difference locked-ids fail-ids)] + + (doseq [id ok-ids] + (l/dbg :hint "permanently delete storage object" + :id (str id) + :backend (name backend-id))) + + (when (seq ok-ids) + (delete-sobjects! conn ok-ids)) + + (when (seq fail-ids) + (increment-attempts-and-defer! conn fail-ids) + (let [given-up (delete-give-up! conn fail-ids)] + (when (pos? (db/get-update-count given-up)) + (l/wrn :hint "giving up on orphan blob after max attempts" + :ids fail-ids + :max-attempts max-attempts)))) + + (count ok-ids)) + 0)) (defn- group-by-backend [items] (d/group-by (comp keyword :backend) :id #{} items)) -(def ^:private sql:get-deleted-sobjects - "SELECT s.* - FROM storage_object AS s - WHERE s.deleted_at IS NOT NULL - AND s.deleted_at <= ? - ORDER BY s.deleted_at ASC") +(def ^:private sql:get-deleted-chunk + "SELECT id, backend + FROM storage_object + WHERE deleted_at IS NOT NULL + AND deleted_at <= ? + AND status = 'valid' + ORDER BY deleted_at ASC + LIMIT ? + FOR UPDATE + SKIP LOCKED") -(defn- get-buckets - [conn] - (let [now (ct/now)] - (sequence - (comp (partition-all 25) - (mapcat group-by-backend)) - (db/cursor conn [sql:get-deleted-sobjects now])))) +(defn- get-deleted-chunk + [conn size] + (db/exec! conn [sql:get-deleted-chunk (ct/now) size])) (defn- clean-deleted! - [{:keys [::db/conn] :as cfg}] - (reduce (fn [total [backend-id ids]] - (let [deleted (delete-in-bulk! cfg backend-id ids)] - (+ total (or deleted 0)))) - 0 - (get-buckets conn))) + [cfg] + (loop [total 0] + (let [deleted (db/tx-run! cfg + (fn [{:keys [::db/conn ::sto/storage]}] + (let [chunk (get-deleted-chunk conn chunk-size)] + (when (seq chunk) + (let [by-backend (group-by-backend chunk)] + (reduce-kv (fn [acc backend-id ids] + (+ acc (process-chunk conn storage backend-id ids))) + 0 + by-backend))))))] + (if deleted + (recur (+ total deleted)) + total)))) (defmethod ig/assert-key ::handler [_ params] (assert (sto/valid-storage? (::sto/storage params)) "expect valid storage") - (assert (db/pool? (::db/pool params)) "expect valid storage")) + (assert (db/pool? (::db/pool params)) "expect valid db pool")) (defmethod ig/init-key ::handler [_ cfg] (fn [_] - (db/tx-run! cfg (fn [cfg] - (let [total (clean-deleted! cfg)] - (l/inf :hint "task finished" :total total) - {:deleted total}))))) + (let [total (clean-deleted! cfg)] + (l/inf :hint "task finished" :total total) + {:deleted total}))) diff --git a/backend/src/app/storage/gc_touched.clj b/backend/src/app/storage/gc_touched.clj index b7ace59ef3..af16af5f82 100644 --- a/backend/src/app/storage/gc_touched.clj +++ b/backend/src/app/storage/gc_touched.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.storage.gc-touched "This task is part of the garbage collection process of storage @@ -23,7 +23,6 @@ [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.time :as ct] - [app.config :as cf] [app.db :as db] [app.storage :as sto] [app.storage.impl :as impl] @@ -108,10 +107,9 @@ WHERE id = ANY(?::uuid[])") (defn- mark-delete-in-bulk! - [conn deletion-delay ids] - (let [ids (db/create-array conn "uuid" ids) - now (ct/plus (ct/now) deletion-delay)] - (db/exec-one! conn [sql:mark-delete-in-bulk now ids]))) + [conn ids] + (let [ids (db/create-array conn "uuid" ids)] + (db/exec-one! conn [sql:mark-delete-in-bulk (ct/now) ids]))) ;; NOTE: A getter that retrieves the key which will be used for group ;; ids; previously we have no value, then we introduced the @@ -149,24 +147,23 @@ :status "delete" :bucket bucket) (recur to-freeze (conj to-delete id) (rest objects)))) - (let [deletion-delay (if (= "tempfile" bucket) - (ct/duration {:hours 2}) - (cf/get-deletion-delay))] + (do (some->> (seq to-freeze) (mark-freeze-in-bulk! conn)) - (some->> (seq to-delete) (mark-delete-in-bulk! conn deletion-delay)) + (some->> (seq to-delete) (mark-delete-in-bulk! conn)) [(count to-freeze) (count to-delete)])))) (defn- process-bucket! [conn bucket objects] - (case bucket - "file-media-object" (process-objects! conn has-file-media-object-refs? bucket objects) - "team-font-variant" (process-objects! conn has-team-font-variant-refs? bucket objects) - "file-object-thumbnail" (process-objects! conn has-file-object-thumbnails-refs? bucket objects) - "file-thumbnail" (process-objects! conn has-file-thumbnails-refs? bucket objects) - "profile" (process-objects! conn has-profile-refs? bucket objects) - "file-data" (process-objects! conn has-file-data-refs? bucket objects) - "tempfile" (process-objects! conn (constantly false) bucket objects) - "organization" (process-objects! conn (constantly false) bucket objects) + (cond + (= bucket "file-media-object") (process-objects! conn has-file-media-object-refs? bucket objects) + (= bucket "team-font-variant") (process-objects! conn has-team-font-variant-refs? bucket objects) + (= bucket "file-object-thumbnail") (process-objects! conn has-file-object-thumbnails-refs? bucket objects) + (= bucket "file-thumbnail") (process-objects! conn has-file-thumbnails-refs? bucket objects) + (= bucket "profile") (process-objects! conn has-profile-refs? bucket objects) + (= bucket "file-data") (process-objects! conn has-file-data-refs? bucket objects) + (= bucket sto/tempfile-bucket) (process-objects! conn (constantly false) sto/tempfile-bucket objects) + (= bucket "organization") (process-objects! conn (constantly false) bucket objects) + :else (ex/raise :type :internal :code :unexpected-unknown-reference :hint (dm/fmt "unknown reference '%'" bucket)))) @@ -185,6 +182,7 @@ FROM storage_object AS so WHERE so.touched_at IS NOT NULL AND so.touched_at <= ? + AND so.status = 'valid' ORDER BY touched_at ASC FOR UPDATE SKIP LOCKED @@ -220,7 +218,9 @@ (defmethod ig/init-key ::handler [_ {:keys [::min-age] :as cfg}] - (fn [_] - (let [threshold (ct/minus (ct/now) min-age)] + (fn [{:keys [props]}] + (let [threshold (if (:skip-delay props) + (ct/now) + (ct/minus (ct/now) min-age))] (process-touched! (assoc cfg ::timestamp threshold))))) diff --git a/backend/src/app/storage/impl.clj b/backend/src/app/storage/impl.clj index a4d61ee532..76fafe2cac 100644 --- a/backend/src/app/storage/impl.clj +++ b/backend/src/app/storage/impl.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.storage.impl "Storage backends abstraction layer." @@ -71,7 +71,10 @@ :code :invalid-storage-backend :context cfg)) -(defmulti del-objects-in-bulk (fn [cfg _] (::sto/type cfg))) +(defmulti del-objects-in-bulk + "Delete multiple objects in bulk. Returns #{fail-ids} — the set of ids + whose blob deletion failed. Empty set = all succeeded." + (fn [cfg _] (::sto/type cfg))) (defmethod del-objects-in-bulk :default [cfg _] @@ -79,6 +82,14 @@ :code :invalid-storage-backend :context cfg)) +(defmulti exists-object? (fn [cfg _] (::sto/type cfg))) + +(defmethod exists-object? :default + [cfg _] + (ex/raise :type :internal + :code :invalid-storage-backend + :context cfg)) + ;; --- HELPERS (defn uuid->hex diff --git a/backend/src/app/storage/pending_gc.clj b/backend/src/app/storage/pending_gc.clj new file mode 100644 index 0000000000..7b8f04ff13 --- /dev/null +++ b/backend/src/app/storage/pending_gc.clj @@ -0,0 +1,88 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.storage.pending-gc + "A maintenance task that reclaims storage objects created in 'pending' + state that were never promoted to 'valid' (e.g. after a crash between + writing the blob and promoting the row). + + Pending rows are invisible to the normal lifecycle (dedup, gc, reads). This + task removes the orphaned blob (if any) and the pending row itself, without + ever iterating the whole physical store." + (:require + [app.common.logging :as l] + [app.db :as db] + [app.storage :as sto] + [app.storage.impl :as impl] + [integrant.core :as ig])) + +(def ^:private sql:get-pending-sobjects + "SELECT id, backend + FROM storage_object + WHERE status = 'pending' + AND created_at <= now() - interval '24 hours' + ORDER BY created_at ASC + LIMIT ? + FOR UPDATE + SKIP LOCKED") + +(defn- get-pending-chunk + [conn chunk-size] + (db/exec! conn [sql:get-pending-sobjects chunk-size])) + +(def ^:private sql:delete-pending-sobject + "DELETE FROM storage_object WHERE id = ? AND status = 'pending'") + +(def ^:private chunk-size + 100) + +(defn- delete-pending-rows! + "Select, lock and delete a chunk of pending rows in a single transaction. + Returns the deleted rows or nil when there is nothing left to reclaim." + [cfg] + (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + ;; NOTE: db/exec! returns an empty vector when there are no + ;; rows left; use not-empty to detect it. + (when-let [chunk (not-empty (get-pending-chunk conn chunk-size))] + (doseq [{:keys [id]} chunk] + (db/exec-one! conn [sql:delete-pending-sobject id])) + chunk)))) + +(defn- delete-blobs! + "Best-effort removal of the orphaned blobs. Runs after the pending rows + have been committed so a failure here never blocks their reclamation." + [storage rows] + (doseq [{:keys [id backend]} rows] + (try + (-> (impl/resolve-backend storage (keyword backend)) + (impl/del-object {:id id})) + (catch Throwable cause + (l/err :hint "error deleting orphaned pending blob" + :id (str id) + :backend backend + :cause cause))))) + +(defn- process! + [{::sto/keys [storage] :as cfg}] + (loop [total 0] + (if-let [rows (delete-pending-rows! cfg)] + (do + (delete-blobs! storage rows) + (recur (long (+ total (count rows))))) + total))) + +(defmethod ig/assert-key ::handler + [_ params] + (assert (db/pool? (::db/pool params)) "expected valid db pool") + (assert (sto/valid-storage? (::sto/storage params)) "expect valid storage")) + +(defmethod ig/init-key ::handler + [_ cfg] + (fn [_] + (let [total (process! cfg)] + (l/inf :hint "task finished" :total total) + {:processed total}))) diff --git a/backend/src/app/storage/s3.clj b/backend/src/app/storage/s3.clj index 025749bee1..6cf97321f1 100644 --- a/backend/src/app/storage/s3.clj +++ b/backend/src/app/storage/s3.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.storage.s3 "S3 Storage backend implementation." @@ -47,6 +47,7 @@ software.amazon.awssdk.services.s3.model.DeleteObjectsRequest software.amazon.awssdk.services.s3.model.DeleteObjectsResponse software.amazon.awssdk.services.s3.model.GetObjectRequest + software.amazon.awssdk.services.s3.model.HeadObjectRequest software.amazon.awssdk.services.s3.model.NoSuchKeyException software.amazon.awssdk.services.s3.model.ObjectIdentifier software.amazon.awssdk.services.s3.model.PutObjectRequest @@ -78,6 +79,7 @@ (declare get-object-url) (declare del-object) (declare del-object-in-bulk) +(declare head-object) (declare build-s3-client) (declare build-s3-presigner) @@ -186,10 +188,46 @@ [backend object] (p/await! (del-object backend object))) +(defmethod impl/exists-object? :s3 + [backend object] + (assert (valid-backend? backend) "expected a valid backend instance") + (loop [result (p/await (head-object backend object)) + retryn 0] + (if (ex/exception? result) + (cond + ;; A missing key is a definitive answer, no need to retry. + (ex/instance? NoSuchKeyException result) + false + + ;; Any other error is considered transient and retried. + (< retryn max-retries) + (do + (Thread/sleep (* 100 (inc retryn))) + (recur (p/await (head-object backend object)) (inc retryn))) + + :else + (throw result)) + true))) + (defmethod impl/del-objects-in-bulk :s3 [backend ids] (assert (valid-backend? backend) "expected a valid backend instance") - (p/await! (del-object-in-bulk backend ids))) + (let [key->id (into {} (map (fn [id] + [(str (::prefix backend) (impl/id->path id)) id])) + ids) + result (try + (p/await! (del-object-in-bulk backend ids)) + (catch Throwable cause + (l/err :hint "error on s3 bulk deletion" + :ids ids + :cause cause) + ::network-error))] + (cond + (= ::network-error result) (set ids) + (map? result) (into #{} (map (fn [{:keys [key]}] + (get key->id key))) + (:errors result)) + :else #{}))) ;; --- HELPERS @@ -330,6 +368,14 @@ ^AsyncResponseTransformer rxf) (p/fmap #(.asInputStream ^ResponseBytes %))))))) +(defn- head-object + [{:keys [::client ::bucket ::prefix]} {:keys [id]}] + (let [hor (.. (HeadObjectRequest/builder) + (bucket bucket) + (key (str prefix (impl/id->path id))) + (build))] + (.headObject ^S3AsyncClient client ^HeadObjectRequest hor))) + (defn- get-object-bytes [{:keys [::client ::bucket ::prefix]} {:keys [id]}] (let [gor (.. (GetObjectRequest/builder) @@ -346,13 +392,21 @@ (ct/duration {:minutes 10})) (defn- get-object-url - [{:keys [::presigner ::bucket ::prefix]} {:keys [id]} {:keys [max-age] :or {max-age default-max-age}}] + [{:keys [::presigner ::bucket ::prefix]} {:keys [id]} + {:keys [max-age content-disposition] :or {max-age default-max-age}}] (assert (ct/duration? max-age) "expected valid duration instance") - (let [gor (.. (GetObjectRequest/builder) + ;; The content-disposition option is signed into the presigned url, so the + ;; object store sets that header on the response the client fetches after + ;; following the redirect. It is only set when asked for, so urls for + ;; objects served inline stay byte identical to before. + (let [gorb (.. (GetObjectRequest/builder) (bucket bucket) - (key (dm/str prefix (impl/id->path id))) - (build)) + (key (dm/str prefix (impl/id->path id)))) + gorb (cond-> gorb + (some? content-disposition) + (.responseContentDisposition ^String content-disposition)) + gor (.build gorb) gopr (.. (GetObjectPresignRequest/builder) (signatureDuration ^Duration max-age) (getObjectRequest ^GetObjectRequest gor) @@ -371,12 +425,11 @@ (defn- del-object-in-bulk [{:keys [::bucket ::client ::prefix]} ids] - - (let [oids (map (fn [id] - (.. (ObjectIdentifier/builder) - (key (str prefix (impl/id->path id))) - (build))) - ids) + (let [oids (mapv (fn [id] + (.. (ObjectIdentifier/builder) + (key (str prefix (impl/id->path id))) + (build))) + ids) delc (.. (Delete/builder) (objects ^Collection oids) (build)) @@ -384,14 +437,9 @@ (bucket bucket) (delete ^Delete delc) (build))] - (->> (.deleteObjects ^S3AsyncClient client ^DeleteObjectsRequest dor) - (p/fmap (fn [dres] - (when (.hasErrors ^DeleteObjectsResponse dres) - (let [errors (seq (.errors ^DeleteObjectsResponse dres))] - (ex/raise :type :internal - :code :error-on-s3-bulk-delete - :s3-errors (mapv (fn [^S3Error error] - {:key (.key error) - :msg (.message error)}) - errors))))))))) + (p/fmap (fn [^DeleteObjectsResponse dres] + (when (.hasErrors dres) + {:errors (mapv (fn [^S3Error e] + {:key (.key e) :msg (.message e)}) + (.errors dres))})))))) diff --git a/backend/src/app/storage/tmp.clj b/backend/src/app/storage/tmp.clj index b7a3076159..7a4ae3b901 100644 --- a/backend/src/app/storage/tmp.clj +++ b/backend/src/app/storage/tmp.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.storage.tmp "Temporal files service all created files will be tried to clean after @@ -80,11 +80,12 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn tempfile* - [& {:keys [suffix prefix] + [& {:keys [suffix prefix dir] :or {prefix "penpot." - suffix ".tmp"}}] + suffix ".tmp" + dir default-tmp-dir}}] (let [attrs (fs/make-permissions "rw-r--r--") - path (fs/join default-tmp-dir (str prefix (uuid/next) suffix))] + path (fs/join dir (str prefix (uuid/next) suffix))] (Files/createFile path attrs))) (defn tempfile diff --git a/backend/src/app/system.clj b/backend/src/app/system.clj index a424bebcf1..487aea1a60 100644 --- a/backend/src/app/system.clj +++ b/backend/src/app/system.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.system) diff --git a/backend/src/app/tasks/delete_object.clj b/backend/src/app/tasks/delete_object.clj index 00d0046f27..95cdabef8b 100644 --- a/backend/src/app/tasks/delete_object.clj +++ b/backend/src/app/tasks/delete_object.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.tasks.delete-object "A generic task for object deletion cascade handling" diff --git a/backend/src/app/tasks/demo_purge.clj b/backend/src/app/tasks/demo_purge.clj new file mode 100644 index 0000000000..429816c053 --- /dev/null +++ b/backend/src/app/tasks/demo_purge.clj @@ -0,0 +1,41 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.tasks.demo-purge + "Task handler for delayed demo profile deletion. Submitted at demo + creation time with a delay matching the configured deletion-delay." + (:require + [app.common.logging :as l] + [app.common.time :as ct] + [app.db :as db] + [app.worker :as wrk] + [integrant.core :as ig])) + +(defmethod ig/assert-key ::handler + [_ params] + (assert (db/pool? (::db/pool params)) "expected a valid database pool")) + +(defmethod ig/init-key ::handler + [_ cfg] + (fn [{:keys [props]}] + (let [profile-id (get props :profile-id) + now (ct/now)] + + (l/trc :hint "demo-purge" :profile-id (str profile-id)) + + ;; Mark the profile for immediate deletion + (db/tx-run! cfg + (fn [{:keys [::db/conn] :as cfg}] + (db/update! conn :profile + {:deleted-at now} + {:id profile-id} + {::db/return-keys false}) + (wrk/submit! + (-> cfg + (assoc ::wrk/task :delete-object) + (assoc ::wrk/params {:object :profile + :deleted-at now + :id profile-id})))))))) diff --git a/backend/src/app/tasks/file_gc.clj b/backend/src/app/tasks/file_gc.clj index 75a92665e4..78461e9b99 100644 --- a/backend/src/app/tasks/file_gc.clj +++ b/backend/src/app/tasks/file_gc.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.tasks.file-gc "A maintenance task that is responsible of: purge unused file media, @@ -251,12 +251,9 @@ (try (-> cfg (assoc ::db/rollback (:rollback? props)) - (db/tx-run! (fn [{:keys [::db/conn] :as cfg}] - (let [cfg (-> cfg - (update ::sto/storage sto/configure conn) - (assoc ::timestamp (ct/now))) - processed? (process-file! cfg props)] - + (assoc ::timestamp (ct/now)) + (db/tx-run! (fn [cfg] + (let [processed? (process-file! cfg props)] (when (and processed? (contains? cf/flags :tiered-file-data-storage)) (wrk/submit! (-> cfg (assoc ::wrk/task :offload-file-data) diff --git a/backend/src/app/tasks/file_gc_scheduler.clj b/backend/src/app/tasks/file_gc_scheduler.clj index 4f4061d814..e70703afaa 100644 --- a/backend/src/app/tasks/file_gc_scheduler.clj +++ b/backend/src/app/tasks/file_gc_scheduler.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.tasks.file-gc-scheduler "A maintenance task that is responsible of properly scheduling the diff --git a/backend/src/app/tasks/objects_gc.clj b/backend/src/app/tasks/objects_gc.clj index ac457d47b0..399bc50beb 100644 --- a/backend/src/app/tasks/objects_gc.clj +++ b/backend/src/app/tasks/objects_gc.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.tasks.objects-gc "A maintenance task that performs a general purpose garbage collection @@ -13,6 +13,7 @@ [app.db :as db] [app.features.fdata :as fdata] [app.storage :as sto] + [app.tasks.delete-object :as dobj] [integrant.core :as ig])) (def ^:private sql:get-profiles @@ -33,6 +34,11 @@ ;; Mark as deleted the storage object (some->> photo-id (sto/touch-object! storage)) + ;; Cascade soft-delete to owned teams, projects, files, etc. + (dobj/delete-object cfg {:object :profile + :id id + :deleted-at timestamp}) + (let [affected (-> (db/delete! conn :profile {:id id}) (db/get-update-count))] (+ total affected))) @@ -321,8 +327,14 @@ (defmethod ig/init-key ::handler [_ cfg] - (fn [_] - (let [cfg (assoc cfg ::timestamp (ct/now))] + (fn [{:keys [props]}] + (let [skip-delay (:skip-delay props) + chunk-size (or (:chunk-size props) (::chunk-size cfg)) + cfg (-> cfg + (assoc ::chunk-size chunk-size) + (assoc ::timestamp (if skip-delay + (ct/in-future {:days 3650}) + (ct/now))))] (loop [procs (map deref deletion-proc-vars) total 0] (if-let [proc-fn (first procs)] diff --git a/backend/src/app/tasks/offload_file_data.clj b/backend/src/app/tasks/offload_file_data.clj index d58eaba308..72dfd44be7 100644 --- a/backend/src/app/tasks/offload_file_data.clj +++ b/backend/src/app/tasks/offload_file_data.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.tasks.offload-file-data "A maintenance task responsible of moving file data from hot diff --git a/backend/src/app/tasks/tasks_gc.clj b/backend/src/app/tasks/tasks_gc.clj index a2a8f4bbc4..ee84e37714 100644 --- a/backend/src/app/tasks/tasks_gc.clj +++ b/backend/src/app/tasks/tasks_gc.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.tasks.tasks-gc "A maintenance task that performs a cleanup of already executed tasks diff --git a/backend/src/app/tasks/telemetry.clj b/backend/src/app/tasks/telemetry.clj index 4fb5f7d904..82696144db 100644 --- a/backend/src/app/tasks/telemetry.clj +++ b/backend/src/app/tasks/telemetry.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.tasks.telemetry "A task that is responsible to collect anonymous statistical diff --git a/backend/src/app/tasks/upload_session_gc.clj b/backend/src/app/tasks/upload_session_gc.clj index ef60f1c598..b5a6a1c078 100644 --- a/backend/src/app/tasks/upload_session_gc.clj +++ b/backend/src/app/tasks/upload_session_gc.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.tasks.upload-session-gc "A maintenance task that deletes stalled (incomplete) upload sessions. diff --git a/backend/src/app/tokens.clj b/backend/src/app/tokens.clj index 71d2a7de88..372a3194ad 100644 --- a/backend/src/app/tokens.clj +++ b/backend/src/app/tokens.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.tokens "Tokens generation API." diff --git a/backend/src/app/util/blob.clj b/backend/src/app/util/blob.clj index afc71653bc..27cd5333ef 100644 --- a/backend/src/app/util/blob.clj +++ b/backend/src/app/util/blob.clj @@ -2,12 +2,13 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.blob "A generic blob storage encoding. Mainly used for page data, page options and txlog payload storage." (:require + [app.common.exceptions :as ex] [app.common.fressian :as fres] [app.common.transit :as t] [app.config :as cf]) @@ -58,12 +59,18 @@ (.encodeToString (.withoutPadding (Base64/getUrlEncoder)) ^bytes (encode data opts)))) (defn decode - "A function used for decode persisted blobs in the database." - [^bytes data] + "A function used for decode persisted blobs in the database. + Accepts optional keyword arguments: + :max-size — maximum allowed uncompressed size in bytes" + [^bytes data & {:keys [max-size]}] (with-open [bais (ByteArrayInputStream. data) dis (DataInputStream. bais)] (let [version (.readShort dis) ulen (.readInt dis)] + (when (and max-size (> ulen max-size)) + (ex/raise :type :validation + :code :blob-too-large + :hint "blob uncompressed size exceeds limit")) (case version 1 (decode-v1 data ulen) 3 (decode-v3 data ulen) @@ -72,9 +79,10 @@ (throw (ex-info "unsupported version" {:version version})))))) (defn decode-str - "Decode a URL-safe base64 string produced by `encode-str` back to data." - [^String s] - (decode (.decode (Base64/getUrlDecoder) s))) + "Decode a URL-safe base64 string produced by `encode-str` back to data. + Accepts the same optional keyword arguments as `decode`." + [^String s & {:as opts}] + (decode (.decode (Base64/getUrlDecoder) s) opts)) ;; --- IMPL diff --git a/backend/src/app/util/cache.clj b/backend/src/app/util/cache.clj index 0414d52c87..d30a5de574 100644 --- a/backend/src/app/util/cache.clj +++ b/backend/src/app/util/cache.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.cache "In-memory cache backed by Caffeine" diff --git a/backend/src/app/util/cron.clj b/backend/src/app/util/cron.clj index b8aeb89120..d3f9607cd8 100644 --- a/backend/src/app/util/cron.clj +++ b/backend/src/app/util/cron.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.cron (:require diff --git a/backend/src/app/util/events.clj b/backend/src/app/util/events.clj index b42971510b..ffbd96a47c 100644 --- a/backend/src/app/util/events.clj +++ b/backend/src/app/util/events.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.events "A generic asynchronous events notifications subsystem; used mainly diff --git a/backend/src/app/util/inet.clj b/backend/src/app/util/inet.clj index 56c9bded0e..438298de5a 100644 --- a/backend/src/app/util/inet.clj +++ b/backend/src/app/util/inet.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.inet "INET addr parsing and validation helpers" diff --git a/backend/src/app/util/json.clj b/backend/src/app/util/json.clj index 80d2068e68..1b5d6cf681 100644 --- a/backend/src/app/util/json.clj +++ b/backend/src/app/util/json.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.json (:require diff --git a/backend/src/app/util/locks.clj b/backend/src/app/util/locks.clj index 0335b1b345..18ae45d313 100644 --- a/backend/src/app/util/locks.clj +++ b/backend/src/app/util/locks.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.locks "A syntactic helpers for using locks." diff --git a/backend/src/app/util/migrations.clj b/backend/src/app/util/migrations.clj index abdb5d87ca..f49f570b13 100644 --- a/backend/src/app/util/migrations.clj +++ b/backend/src/app/util/migrations.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.migrations (:require diff --git a/backend/src/app/util/nio.clj b/backend/src/app/util/nio.clj index 35f5b0c976..6575ed7f3e 100644 --- a/backend/src/app/util/nio.clj +++ b/backend/src/app/util/nio.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.nio "NIO helpers for working with files and byte arrays. diff --git a/backend/src/app/util/objects_map.clj b/backend/src/app/util/objects_map.clj index 0407111e2c..6c4063e192 100644 --- a/backend/src/app/util/objects_map.clj +++ b/backend/src/app/util/objects_map.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.objects-map "Implements a specialized map-like data structure for store an UUID => diff --git a/backend/src/app/util/overrides.clj b/backend/src/app/util/overrides.clj index 4cd9ae464e..8c9ee23af0 100644 --- a/backend/src/app/util/overrides.clj +++ b/backend/src/app/util/overrides.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.overrides "A utility ns for declare default overrides over clojure runtime" diff --git a/backend/src/app/util/pointer_map.clj b/backend/src/app/util/pointer_map.clj index 6c95e89ad7..c8384480ce 100644 --- a/backend/src/app/util/pointer_map.clj +++ b/backend/src/app/util/pointer_map.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.pointer-map "Implements a map-like data structure that provides an entry point for diff --git a/backend/src/app/util/services.clj b/backend/src/app/util/services.clj index 340acb04f3..7e3d69fa72 100644 --- a/backend/src/app/util/services.clj +++ b/backend/src/app/util/services.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.services "A helpers and macros for define rpc like registry based services." diff --git a/backend/src/app/util/shell.clj b/backend/src/app/util/shell.clj index 61dd08e682..d705b02b3a 100644 --- a/backend/src/app/util/shell.clj +++ b/backend/src/app/util/shell.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.shell "A penpot specific, modern api for executing external (shell) diff --git a/backend/src/app/util/ssrf.clj b/backend/src/app/util/ssrf.clj index 2dc68afb98..0096eabb1c 100644 --- a/backend/src/app/util/ssrf.clj +++ b/backend/src/app/util/ssrf.clj @@ -2,10 +2,15 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.ssrf - "URL/host validation to prevent Server-Side Request Forgery." + "URL/host validation to prevent Server-Side Request Forgery. + + The blocklist covers the standard JVM InetAddress classifications plus + explicit ranges: IPv6 ULA, IPv4-mapped loopback, cloud metadata, + operator-supplied CIDRs and the IPv6 transition mechanisms NAT64, 6to4 + and Teredo." (:require [app.common.exceptions :as ex] [app.common.logging :as l] @@ -122,6 +127,20 @@ ;; Check the embedded IPv4 is loopback (127.x.x.x) (= (bit-and (aget bs 12) 0xFF) 127)))) +(defn- transition-prefix + "Classify a 16-byte IPv6 address into its transition mechanism: + :nat64 (64:ff9b::/96), :6to4 (2002::/16), :teredo (2001:0000::/32) or nil." + [^bytes bs] + (let [b0 (bit-and (aget bs 0) 0xFF) + b1 (bit-and (aget bs 1) 0xFF) + b2 (bit-and (aget bs 2) 0xFF) + b3 (bit-and (aget bs 3) 0xFF)] + (cond + (and (= b0 0x00) (= b1 0x64) (= b2 0xFF) (= b3 0x9B)) :nat64 + (and (= b0 0x20) (= b1 0x02)) :6to4 + (and (= b0 0x20) (= b1 0x01) (= b2 0x00) (= b3 0x00)) :teredo + :else nil))) + (defn- blocked-address? "Check if an InetAddress should be blocked. Returns true if blocked." [^InetAddress addr] @@ -141,12 +160,15 @@ ;; Cloud metadata IPs (exact match) (contains? cloud-metadata-ips (.getHostAddress addr)) - ;; Extra blocked CIDRs (IPv4 only) + ;; Extra blocked CIDRs (IPv4 only) and IPv6 transition mechanisms (let [bs (.getAddress addr)] (if (= (alength bs) 4) (or (some #(in-cidr4? bs %) extra-blocked-ranges) (some #(in-cidr4? bs %) extra-blocked-cidrs)) - false)))) + ;; IPv6 transition mechanisms (NAT64/6to4/Teredo): the range is + ;; rejected outright. + (boolean (when (= (alength bs) 16) + (transition-prefix bs))))))) (defn resolve-host "Resolve a hostname to all InetAddress objects. Wraps InetAddress/getAllByName @@ -163,8 +185,10 @@ - host must resolve to at least one address, and - **every** resolved address must NOT be in the blocklist (loopback, link-local, site-local, multicast, any-local, - cloud-metadata 169.254.169.254, IPv6 ULA fc00::/7, IPv4-mapped - IPv6 of any blocked IPv4, plus operator-supplied CIDRs). + cloud-metadata 169.254.169.254, IPv6 ULA fc00::/7, IPv6 transition + mechanisms NAT64 64:ff9b::/96, 6to4 2002::/16 and Teredo + 2001:0000::/32, IPv4-mapped IPv6 of any blocked IPv4, + plus operator-supplied CIDRs). When the host is an IP literal (decimal/octal/hex/IPv6) it is normalized via `com.google.common.net.InetAddresses` before the check. diff --git a/backend/src/app/util/template.clj b/backend/src/app/util/template.clj index dabad557ac..581a355c87 100644 --- a/backend/src/app/util/template.clj +++ b/backend/src/app/util/template.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.template (:require diff --git a/backend/src/app/util/websocket.clj b/backend/src/app/util/websocket.clj index d62dd6b81f..514de1178c 100644 --- a/backend/src/app/util/websocket.clj +++ b/backend/src/app/util/websocket.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.websocket "A general protocol implementation on top of websockets using vthreads." diff --git a/backend/src/app/worker.clj b/backend/src/app/worker.clj index 1280876b32..09fc741ea0 100644 --- a/backend/src/app/worker.clj +++ b/backend/src/app/worker.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker "Async tasks abstraction (impl)." diff --git a/backend/src/app/worker/cron.clj b/backend/src/app/worker/cron.clj index 46067815b4..f61f607bff 100644 --- a/backend/src/app/worker/cron.clj +++ b/backend/src/app/worker/cron.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.cron (:require diff --git a/backend/src/app/worker/dispatcher.clj b/backend/src/app/worker/dispatcher.clj index 7a0ec75362..8c128e177a 100644 --- a/backend/src/app/worker/dispatcher.clj +++ b/backend/src/app/worker/dispatcher.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.dispatcher (:require diff --git a/backend/src/app/worker/executor.clj b/backend/src/app/worker/executor.clj index b536e2709e..144526e269 100644 --- a/backend/src/app/worker/executor.clj +++ b/backend/src/app/worker/executor.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.executor "Async tasks abstraction (impl)." diff --git a/backend/src/app/worker/runner.clj b/backend/src/app/worker/runner.clj index e85144c521..d6211ca770 100644 --- a/backend/src/app/worker/runner.clj +++ b/backend/src/app/worker/runner.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.runner "Async tasks abstraction (impl)." diff --git a/backend/test/backend_tests/auth_oidc_test.clj b/backend/test/backend_tests/auth_oidc_test.clj index 22ccb624fe..d95a12af8c 100644 --- a/backend/test/backend_tests/auth_oidc_test.clj +++ b/backend/test/backend_tests/auth_oidc_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.auth-oidc-test (:require @@ -15,6 +15,7 @@ [app.setup :as-alias setup] [app.tokens :as tokens] [clojure.test :as t] + [cuerdas.core :as str] [mockery.core :refer [with-mocks]] [yetti.response :as-alias yres])) @@ -385,6 +386,9 @@ (def ^:private test-profile-id #uuid "11111111-1111-1111-1111-111111111111") +(def ^:private test-organization-id + #uuid "22222222-2222-2222-2222-222222222222") + (def ^:private test-profile {:id test-profile-id :is-active true @@ -518,3 +522,204 @@ loc (redirect-location result)] (t/is (= 302 (::yres/status result))) (t/is (.contains loc "error=unable-to-auth"))))))) + +(t/deftest organization-sso-callback-success-emits-succeeded + (let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist) + state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace" + :organization-id test-organization-id}) + request (default-request cfg :state state) + events (atom [])] + (with-redefs [app.nitrate/call (constantly {:active true}) + app.auth.oidc/prepare-organization-sso-provider (constantly {:type "oidc"}) + app.auth.oidc/get-info (constantly {}) + app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))] + (let [result (#'oidc/callback-handler cfg request)] + (t/is (= "https://penpot.example.com/#/workspace" (redirect-location result))) + (t/is (= ["organization-sso-auth-succeeded"] (mapv :name @events))) + (t/is (= test-organization-id (get-in (first @events) [:props :organization-id]))))))) + +(t/deftest organization-sso-callback-error-emits-failed + (let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist) + state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace" + :organization-id test-organization-id}) + request (default-request cfg :state state) + events (atom [])] + (with-redefs [app.nitrate/call (fn [_cfg method _params] + (case method + :get-organization-sso {:active true} + :get-organization-summary {:name "Organization"})) + app.auth.oidc/prepare-organization-sso-provider (constantly {:type "oidc"}) + app.auth.oidc/get-info (fn [& _] + (ex/raise :type :internal + :code :unable-to-retrieve-user-info)) + app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))] + (#'oidc/callback-handler cfg request) + (t/is (= ["organization-sso-auth-failed"] (mapv :name @events))) + (t/is (= {:organization-id test-organization-id + :failure-reason "user-info-failed"} + (:props (first @events))))))) + +(t/deftest organization-sso-oauth-error-emits-failed-without-changing-redirect + (let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist) + state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace" + :organization-id test-organization-id}) + request (assoc-in (default-request cfg :state state) [:params :error] "access_denied") + events (atom [])] + (binding [cf/config {:public-uri "http://localhost:3449"}] + (with-redefs [app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))] + (let [result (#'oidc/callback-handler cfg request) + loc (redirect-location result)] + (t/is (.contains loc "error=unable-to-auth")) + (t/is (.contains loc "hint=access_denied")) + (t/is (= ["organization-sso-auth-failed"] (mapv :name @events))) + (t/is (= {:organization-id test-organization-id + :failure-reason "access-denied"} + (:props (first @events))))))))) + +(t/deftest prepare-organization-sso-provider-does-not-skip-ssrf-check + (t/testing "organization SSO provider must use SSRF protection" + (let [captured-params (atom nil)] + (with-redefs [oidc/prepare-oidc-provider (fn [_cfg params] + (reset! captured-params params) + {:type "oidc" :id "test"})] + (#'oidc/prepare-organization-sso-provider {} + {:client-id "test-client" + :client-secret "test-secret" + :issuer "https://idp.example.com"}) + (t/is (not (true? (:skip-ssrf-check? @captured-params))) + "SSRF protection must be disabled for organization SSO"))))) + +(defn- ssl-handshake-failure + [] + (javax.net.ssl.SSLHandshakeException. "Remote host terminated the handshake")) + +(t/deftest prepare-organization-sso-provider-raises-on-discovery-network-failure + (t/testing "SSL/network failures during OIDC discovery become controlled validation errors" + (with-mocks [http-mock {:target 'app.http.client/req + :side-effect (fn [& _] (throw (ssl-handshake-failure)))}] + (let [e (try + (#'oidc/prepare-organization-sso-provider + {} + {:client-id "test-client" + :client-secret "test-secret" + :issuer "https://wrong-idp.example.com"}) + (catch Throwable t t))] + (t/is (ex/error? e)) + (t/is (= :validation (:type (ex-data e)))) + (t/is (= :invalid-sso-config (:code (ex-data e)))))))) + +(t/deftest prepare-organization-sso-provider-raises-on-discovery-non-200 + (t/testing "non-200 OIDC discovery responses become controlled validation errors" + (with-mocks [http-mock {:target 'app.http.client/req + :return {:status 404 :body "not found"}}] + (let [e (try + (#'oidc/prepare-organization-sso-provider + {} + {:client-id "test-client" + :client-secret "test-secret" + :issuer "https://idp.example.com"}) + (catch Throwable t t)) + data (ex-data e)] + (t/is (ex/error? e)) + (t/is (= :validation (:type data))) + (t/is (= :invalid-sso-config (:code data))) + (t/is (= 404 (:response-status-code data))) + (t/is (= "unable to discover OIDC configuration" (ex-message e))) + (t/is (str/includes? (str (:discover-uri data)) "openid-configuration")))))) + +(t/deftest prepare-organization-sso-provider-raises-on-ssrf-blocked-issuer + (t/testing "SSRF/DNS failures for the issuer URL become invalid-sso-config, not ssrf-blocked-target" + (with-mocks [http-mock {:target 'app.http.client/req + :side-effect (fn [& _] + (ex/raise :type :validation + :code :ssrf-blocked-target + :hint "uri host could not be resolved"))}] + (let [e (try + (#'oidc/prepare-organization-sso-provider + {} + {:client-id "test-client" + :client-secret "test-secret" + :issuer "https://unresolvable.invalid"}) + (catch Throwable t t))] + (t/is (ex/error? e)) + (t/is (= :validation (:type (ex-data e)))) + (t/is (= :invalid-sso-config (:code (ex-data e)))) + (t/is (= :ssrf-blocked-target (:code (ex-data (ex-cause e))))))))) + +(t/deftest prepare-organization-sso-provider-raises-on-jwks-network-failure + (t/testing "SSL/network failures while fetching JWKs become controlled validation errors" + (let [discovery-body (str "{\"authorization_endpoint\":\"https://idp.example.com/auth\"," + "\"token_endpoint\":\"https://idp.example.com/token\"," + "\"userinfo_endpoint\":\"https://idp.example.com/userinfo\"," + "\"jwks_uri\":\"https://idp.example.com/jwks\"}")] + (with-mocks [http-mock {:target 'app.http.client/req + :side-effect (fn [_cfg request & _] + (if (str/includes? (str (:uri request)) "openid-configuration") + {:status 200 :body discovery-body} + (throw (ssl-handshake-failure))))}] + (let [e (try + (#'oidc/prepare-organization-sso-provider + {} + {:client-id "test-client" + :client-secret "test-secret" + :issuer "https://idp.example.com"}) + (catch Throwable t t))] + (t/is (ex/error? e)) + (t/is (= :validation (:type (ex-data e)))) + (t/is (= :invalid-sso-config (:code (ex-data e))))))))) + +(t/deftest populate-jwks-strict-wraps-non-invalid-sso-config-errors + (t/testing "strict JWKS path wraps unrelated structured errors instead of rethrowing them" + (with-mocks [fetch-mock {:target 'app.auth.oidc/fetch-oidc-jwks + :side-effect (fn [& _] + (ex/raise :type :validation + :code :ssrf-blocked-target + :hint "uri host could not be resolved"))}] + (let [e (try + (#'oidc/populate-jwks + {} + {:id "oidc" + :jwks-uri "https://idp.example.com/jwks" + :strict-jwks? true}) + (catch Throwable t t))] + (t/is (ex/error? e)) + (t/is (= :validation (:type (ex-data e)))) + (t/is (= :invalid-sso-config (:code (ex-data e)))) + (t/is (= :ssrf-blocked-target (:code (ex-data (ex-cause e))))))))) + +(t/deftest populate-jwks-strict-rethrows-invalid-sso-config + (t/testing "strict JWKS path rethrows an already-controlled invalid-sso-config" + (with-mocks [fetch-mock {:target 'app.auth.oidc/fetch-oidc-jwks + :side-effect (fn [& _] + (ex/raise :type :validation + :code :invalid-sso-config + :hint "unable to retrieve JWKs" + :jwks-uri "https://idp.example.com/jwks"))}] + (let [e (try + (#'oidc/populate-jwks + {} + {:id "oidc" + :jwks-uri "https://idp.example.com/jwks" + :strict-jwks? true}) + (catch Throwable t t))] + (t/is (ex/error? e)) + (t/is (= :invalid-sso-config (:code (ex-data e)))) + (t/is (= "unable to retrieve JWKs" (ex-message e))) + (t/is (= "https://idp.example.com/jwks" (:jwks-uri (ex-data e)))))))) + +(t/deftest build-organization-sso-auth-redirect-uri-raises-on-unreachable-provider + (t/testing "check-nitrate-sso path surfaces a controlled error when the issuer is unreachable" + (with-mocks [http-mock {:target 'app.http.client/req + :side-effect (fn [& _] (throw (ssl-handshake-failure)))}] + (let [e (try + (oidc/build-organization-sso-auth-redirect-uri + {} + {:client-id "test-client" + :client-secret "test-secret" + :issuer "https://wrong-idp.example.com"} + :dest-url "https://localhost:3449/#/dashboard" + :organization-id #uuid "00000000-0000-0000-0000-000000000001") + (catch Throwable t t))] + (t/is (ex/error? e)) + (t/is (= :validation (:type (ex-data e)))) + (t/is (= :invalid-sso-config (:code (ex-data e)))))))) diff --git a/backend/test/backend_tests/binfile_test.clj b/backend/test/backend_tests/binfile_test.clj index 232a44f87c..1f1caa6182 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -2,29 +2,39 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.binfile-test "Internal binfile test, no RPC involved" (:require [app.binfile.common :as bfc] + [app.binfile.v1 :as v1] [app.binfile.v3 :as v3] + [app.common.data :as d] [app.common.features :as cfeat] + [app.common.files.validate :as cfv] [app.common.pprint :as pp] [app.common.thumbnails :as thc] + [app.common.time :as ct] [app.common.types.shape :as cts] [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as db] [app.db.sql :as sql] [app.http :as http] [app.rpc :as-alias rpc] + [app.rpc.commands.binfile :as binfile] [app.storage :as sto] [app.storage.tmp :as tmp] [backend-tests.helpers :as th] + [backend-tests.storage-test :as stt] [clojure.test :as t] [cuerdas.core :as str] [datoteka.fs :as fs] - [datoteka.io :as io])) + [datoteka.io :as io]) + (:import + java.io.ByteArrayInputStream + java.io.DataInputStream)) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -86,6 +96,102 @@ (dissoc file :data))) +(def ^:private svg-raw-page-id (uuid/custom 1 1)) +(def ^:private svg-raw-root-id (uuid/custom 3 1)) +(def ^:private svg-raw-child-id (uuid/custom 3 2)) + +(defn- prepare-svg-raw-file + "A file containing an svg-raw subtree (an svg-raw parent with an + svg-raw child), which is what importing an SVG produces." + [profile] + (let [page-id svg-raw-page-id + root-id svg-raw-root-id + child-id svg-raw-child-id + + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :vern 0 + :changes + [{:type :add-page + :name "page 1" + :id page-id}]) + + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :vern 0 + :changes + [{:type :add-obj + :page-id page-id + :id root-id + :parent-id uuid/zero + :frame-id uuid/zero + :components-v2 true + :obj (cts/setup-shape + {:id root-id + :name "svg-root" + :frame-id uuid/zero + :parent-id uuid/zero + :type :svg-raw + :content {:tag :svg :attrs {} :content []}})} + {:type :add-obj + :page-id page-id + :id child-id + :parent-id root-id + :frame-id uuid/zero + :components-v2 true + :obj (cts/setup-shape + {:id child-id + :name "svg-text" + :frame-id uuid/zero + :parent-id root-id + :type :svg-raw + :content {:tag :text :attrs {} :content []}})}]) + + (dissoc file :data))) + +(t/deftest import-binfile-v3-preserves-svg-raw-children + (let [profile (th/create-profile* 1) + file (prepare-svg-raw-file profile) + output (tmp/tempfile :suffix ".zip")] + + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :detach-libraries)) + (io/output-stream output)) + + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/input output) + (v3/import-files!)) + file-id (first (:file-ids result)) + imported (:result (th/command! {::th/type :get-file + ::rpc/profile-id (:id profile) + :id file-id + :components-v2 true})) + root (get-in imported [:data :pages-index svg-raw-page-id + :objects svg-raw-root-id])] + + (t/is (= 1 (count (:file-ids result)))) + + ;; The child ids of an svg-raw shape must survive the JSON round + ;; trip as uuids; when they came back as plain strings they no + ;; longer resolved against the objects map. + (t/is (every? uuid? (:shapes root))) + (t/is (= [svg-raw-child-id] (vec (:shapes root)))) + + ;; ...so the imported file passes referential integrity instead + ;; of failing with :child-not-found on the next update-file. + (t/is (nil? (cfv/validate-file imported [])))))) + (t/deftest export-binfile-v3 (let [profile (th/create-profile* 1) file (prepare-simple-file profile) @@ -94,8 +200,7 @@ (v3/export-files! (-> th/*system* (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/embed-assets false) - (assoc ::bfc/include-libraries false)) + (assoc ::bfc/export-type :detach-libraries)) (io/output-stream output)) (let [result (-> th/*system* @@ -103,5 +208,1762 @@ (assoc ::bfc/profile-id (:id profile)) (assoc ::bfc/input output) (v3/import-files!))] - (t/is (= (count result) 1)) - (t/is (every? uuid? result))))) + (t/is (map? result)) + (t/is (= 1 (count (:file-ids result)))) + (t/is (every? uuid? (:file-ids result))) + ;; No external libraries in simple case - resolution should be empty + (t/is (= {} (:resolution result)))))) + +(t/deftest export-binfile-preserves-public-uri-subpath + (let [profile (th/create-profile* 1) + file (prepare-simple-file profile) + config (assoc cf/config :public-uri "https://example.com/penpot") + params {:file-id (:id file) + ::bfc/export-type :detach-libraries} + uri (binding [cf/config config] + (#'binfile/export-binfile th/*system* params))] + (t/is (str/starts-with? (str uri) + "https://example.com/penpot/assets/by-id/")))) + +(t/deftest import-binfile-v3-persists-manifest-metadata + (let [profile (th/create-profile* 1) + file (prepare-simple-file profile) + output (tmp/tempfile :suffix ".zip")] + + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :detach-libraries)) + (io/output-stream output)) + + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/input output) + (v3/import-files!)) + imported (bfc/get-file th/*system* (first (:file-ids result)))] + + (t/is (= 1 (count (:file-ids result)))) + (t/is (some? (get-in imported [:metadata :generated-by]))) + (t/is (= "penpot" (get-in imported [:metadata :referer])))))) + +(t/deftest read-obj-rejects-oversized-buffer + ;; N1-07: read-obj! must reject objects exceeding max-object-size + ;; before attempting to allocate the buffer + (let [size (+ bfc/max-object-size 1) + baos (java.io.ByteArrayOutputStream. 17) + dos (java.io.DataOutputStream. baos)] + (.writeByte dos 5) + (.writeLong dos (long size)) + (.flush dos) + (let [input (java.io.DataInputStream. + (ByteArrayInputStream. (.toByteArray baos)))] + (binding [v1/*position* (atom 0)] + (let [out (try + (v1/read-obj! input) + nil + (catch clojure.lang.ExceptionInfo e + (ex-data e)))] + ;; Without the guard, read-obj! will either OOM or proceed + ;; to read-bytes! on a truncated stream (no :max-file-size-reached). + ;; With the guard, it raises :validation :max-file-size-reached. + (t/is (= :validation (:type out))) + (t/is (= :max-file-size-reached (:code out)))))))) + +(t/deftest slugify-name-test + (t/is (= "my-design-system" (bfc/slugify-name "My Design System!"))) + (t/is (= "icons" (bfc/slugify-name "Icons"))) + (t/is (= "brand-colors-2024" (bfc/slugify-name "Brand Colors 2024"))) + (t/is (= "" (bfc/slugify-name "---")))) + +(t/deftest export-includes-external-libraries + (let [profile (th/create-profile* 1) + ;; Create a shared library file + library (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + ;; Create a file that uses the library + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; Link file to library + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) + :library-file-id (:id library)}) + + ;; Export without including libraries + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Read the manifest and check external-libraries + (let [manifest (v3/get-manifest output)] + (t/is (some? (:external-libraries manifest))) + (t/is (= 1 (count (:external-libraries manifest)))) + (let [ext-lib (first (:external-libraries manifest))] + (t/is (= (:id library) (:id ext-lib))) + (t/is (= "Icons Library" (:name ext-lib))) + (t/is (= "icons-library" (:slug ext-lib))) + (t/is (= [(:id file)] (:used-by ext-lib)))))))) + +(t/deftest import-auto-links-single-candidate + (let [profile (th/create-profile* 1) + ;; Create a shared library file + library (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + ;; Create a file that uses the library + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; Link file to library + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) + :library-file-id (:id library)}) + + ;; Export without including libraries + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Remove the source library to simulate a cross-environment import + ;; where the original library does not exist in the target team. + (db/update! th/*system* :file + {:deleted-at (ct/now)} + {:id (:id library)}) + + ;; Now create a new shared library with the same name in the same team + ;; (simulating the library existing in the target environment) + (let [library2 (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/input output) + (v3/import-files!))] + + ;; Check that the library was auto-linked in the resolution + (let [resolution (:resolution result) + file-id (first (:file-ids result)) + file-res (get resolution file-id)] + ;; File should have name + (t/is (some? (:name file-res))) + ;; File should have one auto-linked library in :done + (t/is (= 1 (count (:done file-res)))) + (let [done-entry (first (:done file-res))] + (t/is (= (:id library) (:id done-entry))) + (t/is (= (:id library2) (:linked-to done-entry)))) + ;; No pending candidates + (t/is (= [] (:pending file-res)))) + + ;; Verify the file-library-rel was created + (let [rels (db/query th/*system* :file-library-rel + {:library-file-id (:id library2)})] + (t/is (= 1 (count rels)))))))) + +(t/deftest import-no-auto-link-no-match + (let [profile (th/create-profile* 1) + ;; Create a shared library file + library (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + ;; Create a file that uses the library + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; Link file to library + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) + :library-file-id (:id library)}) + + ;; Export without including libraries + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :detach-libraries)) + (io/output-stream output)) + + ;; Remove the source library to simulate a cross-environment import + ;; where no matching library exists in the target team. + (db/update! th/*system* :file + {:deleted-at (ct/now)} + {:id (:id library)}) + + ;; Import without any matching library in the team + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/input output) + (v3/import-files!))] + + ;; No auto-linking should happen - resolution should be empty + (t/is (= {} (:resolution result))))))) + +(t/deftest import-returns-multi-match-candidates + (let [profile (th/create-profile* 1) + ;; Create a shared library file + library (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + ;; Create a file that uses the library + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; Link file to library + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) + :library-file-id (:id library)}) + + ;; Export without including libraries + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Remove the source library to simulate a cross-environment import + ;; where the original library does not exist in the target team. + (db/update! th/*system* :file + {:deleted-at (ct/now)} + {:id (:id library)}) + + ;; Create TWO shared libraries with the same name + (let [library2 (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + library3 (th/create-file* 4 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/input output) + (v3/import-files!))] + + ;; No auto-linking (multi-match) - check resolution structure + (let [resolution (:resolution result) + file-id (first (:file-ids result)) + file-res (get resolution file-id)] + ;; File should have name + (t/is (some? (:name file-res))) + ;; No auto-linked libraries + (t/is (= [] (:done file-res))) + ;; Should have pending candidates + (t/is (= 1 (count (:pending file-res)))) + (let [pending-entry (first (:pending file-res))] + (t/is (= (:id library) (:id pending-entry))) + (t/is (= 2 (count (:candidates pending-entry)))) + ;; Each candidate should have project info + (doseq [candidate (:candidates pending-entry)] + (t/is (some? (:project-id candidate))) + (t/is (some? (:project-name candidate)))))) + + ;; No file-library-rel should be created automatically + (let [rels (db/query th/*system* :file-library-rel + {:library-file-id (:id library2)})] + (t/is (= 0 (count rels)))) + (let [rels (db/query th/*system* :file-library-rel + {:library-file-id (:id library3)})] + (t/is (= 0 (count rels)))))))) + +(t/deftest import-auto-link-respects-library-permissions + (let [owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + viewer (th/create-profile* 2) + _ (th/create-team-role* {:team-id (:id team) + :profile-id (:id viewer) + :role :viewer}) + + library (th/create-file* 1 {:profile-id (:id owner) + :project-id (:default-project-id owner) + :is-shared true + :name "Icons Library"}) + file (th/create-file* 2 {:profile-id (:id owner) + :project-id (:default-project-id owner) + :is-shared false})] + + ;; Link file to library + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) + :library-file-id (:id library)}) + + ;; Export with link-later to compute external-libraries + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Remove the source library and recreate a matching one owned by owner + (db/update! th/*system* :file + {:deleted-at (ct/now)} + {:id (:id library)}) + + ;; Create a project in the team for the matched library and import. + (let [project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + + library2 (th/create-file* 3 {:profile-id (:id owner) + :project-id (:id project) + :is-shared true + :name "Icons Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:id project)) + (assoc ::bfc/profile-id (:id viewer)) + (assoc ::bfc/team-id (:id team)) + (assoc ::bfc/input output) + (v3/import-files!))] + + ;; Auto-link must be skipped because viewer cannot edit the library + (let [resolution (:resolution result) + file-id (first (:file-ids result)) + file-res (get resolution file-id)] + ;; No auto-linked libraries - file may not be in resolution map at all + (t/is (or (nil? file-res) + (= [] (:done file-res))))) + + ;; No file-library-rel should have been created + (let [rels (db/query th/*system* :file-library-rel + {:library-file-id (:id library2)})] + (t/is (= 0 (count rels)))) + + ;; Control: the same import performed by the owner (who has edit + ;; permission on the library) should auto-link. + (let [result (-> th/*system* + (assoc ::bfc/project-id (:id project)) + (assoc ::bfc/profile-id (:id owner)) + (assoc ::bfc/team-id (:id team)) + (assoc ::bfc/input output) + (v3/import-files!))] + + (let [resolution (:resolution result) + file-id (first (:file-ids result)) + file-res (get resolution file-id)] + ;; Should have name + (t/is (some? (:name file-res))) + ;; Should have one auto-linked library + (t/is (= 1 (count (:done file-res)))) + (t/is (= (:id library2) (:linked-to (first (:done file-res)))))) + + (let [rels (db/query th/*system* :file-library-rel + {:library-file-id (:id library2)})] + (t/is (= 1 (count rels))))))))) + +(t/deftest import-auto-link-only-files-that-used-library + (let [profile (th/create-profile* 1) + library (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + file1 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + file2 (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; Only file1 uses the library + (db/insert! th/*system* :file-library-rel + {:file-id (:id file1) + :library-file-id (:id library)}) + + ;; Export both files without including libraries + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file1) (:id file2)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Remove the source library and recreate a matching one + (db/update! th/*system* :file + {:deleted-at (ct/now)} + {:id (:id library)}) + + (let [library2 (th/create-file* 4 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/input output) + (v3/import-files!))] + + ;; The library should be auto-linked for the file that used it + (let [resolution (:resolution result) + ;; Find the file that has auto-linked libraries + file-with-done (d/seek #(seq (:done %)) (vals resolution))] + (t/is (some? file-with-done)) + ;; Should have name + (t/is (some? (:name file-with-done))) + (t/is (= 1 (count (:done file-with-done)))) + (t/is (= (:id library2) (:linked-to (first (:done file-with-done))))) + + ;; But only one file-library-rel should exist (for file1) + (let [rels (db/query th/*system* :file-library-rel + {:library-file-id (:id library2)})] + (t/is (= 1 (count rels))))))))) + +;; ============================================================================= +;; COMPREHENSIVE LINK-LATER TESTS +;; ============================================================================= + +(defn- import-sample-file + "Import the file-with-library.penpot sample file and return + {:profile :file :library :team}. The sample contains a library and + a file that uses it." + ([] + (import-sample-file th/*system*)) + ([system] + (let [profile (th/create-profile* system 1 {}) + input (th/tempfile "backend_tests/test_files/file-with-library.penpot") + result (-> system + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/input input) + (v3/import-files!)) + file-ids (:file-ids result) + ;; Find the file-library-rel to identify which id is the file + ;; and which is the library. Relation: file-id -> library-file-id. + rels (keep #(when-let [r (db/query system :file-library-rel + {:file-id %})] + (first r)) + file-ids) + rel (first rels) + file-id (:file-id rel) + library-id (:library-file-id rel)] + {:profile profile + :file-id file-id + :library-id library-id + :all-file-ids (set file-ids) + :team-id (:default-team-id profile)}))) + +(defn- create-named-library + "Create a shared library with the given name in the given team." + ([team-id name] + (create-named-library th/*system* 1 team-id name)) + ([system i team-id name] + (let [profile (th/create-profile* system i {}) + project (th/create-project* system i {:profile-id (:id profile) + :team-id team-id})] + (th/create-file* system i {:profile-id (:id profile) + :project-id (:id project) + :is-shared true + :name name})))) + +(defn- get-file-shapes + "Get all shapes from a file's data." + [file-data] + (let [pages (vals (:pages-index file-data))] + (mapcat vals (map :objects pages)))) + +;; ----------------------------------------------------------------------------- +;; Category 1: Same-Team Round-Trip +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-same-team-round-trip + (let [{:keys [profile file-id team-id]} (import-sample-file) + _ (t/is (some? file-id)) + output (tmp/tempfile :suffix ".zip")] + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Verify manifest has external-libraries + (let [manifest (v3/get-manifest output) + ext-libs (:external-libraries manifest)] + (t/is (some? ext-libs)) + (t/is (pos? (count ext-libs)))) + + ;; Re-import in same team + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Should have auto-linked library + (t/is (some? file-res)) + (t/is (some? (:name file-res))) + (t/is (= 1 (count (:done file-res)))) + (t/is (= [] (:pending file-res))) + + ;; Verify file-library-rel was created + (let [rels (db/query th/*system* :file-library-rel {:file-id new-file-id})] + (t/is (pos? (count rels))))))) + +(t/deftest link-later-same-team-idempotent + (let [{:keys [profile file-id team-id]} (import-sample-file) + output (tmp/tempfile :suffix ".zip")] + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; First import + (let [result1 (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!))] + (t/is (= 1 (count (:file-ids result1))))) + + ;; Second import (should succeed) + (let [result2 (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!))] + (t/is (= 1 (count (:file-ids result2)))) + (let [resolution (:resolution result2) + new-file-id (first (:file-ids result2)) + file-res (get resolution new-file-id)] + (t/is (some? file-res)) + (t/is (= 1 (count (:done file-res)))))))) + +(t/deftest link-later-overwrite-import-no-resolution + (let [{:keys [profile file-id team-id]} (import-sample-file) + output (tmp/tempfile :suffix ".zip")] + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import with overwrite (file-id set) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/file-id file-id) + (assoc ::bfc/input output) + (v3/import-files!))] + ;; Overwrite should have empty resolution + (t/is (= {} (:resolution result)))))) + +;; ----------------------------------------------------------------------------- +;; Category 2: Cross-Team Migration +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-cross-team-library-pre-exists + (let [{:keys [profile file-id team-id]} (import-sample-file) + ;; Create a second team with a library named "LIbrary" + team2 (th/create-team* 2 {:profile-id (:id profile)}) + library2 (create-named-library th/*system* 10 (:id team2) "LIbrary") + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later from team 1 + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import in team 2 (where library with same name exists) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:id team2)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Should auto-link to library2 + (t/is (some? file-res)) + (t/is (= 1 (count (:done file-res)))) + (t/is (= (:id library2) (:linked-to (first (:done file-res))))) + + ;; Verify file-library-rel was created + (let [rels (db/query th/*system* :file-library-rel + {:library-file-id (:id library2)})] + (t/is (= 1 (count rels))))))) + +(t/deftest link-later-cross-team-no-library + (let [{:keys [profile file-id]} (import-sample-file) + team2 (th/create-team* 2 {:profile-id (:id profile)}) + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import in team 2 (no library exists) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:id team2)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; No linking should happen + (t/is (= {} resolution))))) + +(t/deftest link-later-cross-team-different-library-name + (let [{:keys [profile file-id]} (import-sample-file) + team2 (th/create-team* 2 {:profile-id (:id profile)}) + library2 (create-named-library th/*system* 10 (:id team2) "Buttons Library") + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import in team 2 (library with different name) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:id team2)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; No linking (slug mismatch) + (t/is (= {} resolution))))) + +(t/deftest link-later-cross-team-library-not-shared + (let [{:keys [profile file-id]} (import-sample-file) + team2 (th/create-team* 2 {:profile-id (:id profile)}) + ;; Create a private (non-shared) library + _ (let [priv-project (th/create-project* th/*system* 20 {:profile-id (:id profile) + :team-id (:id team2)}) + priv-lib (th/create-file* th/*system* 21 {:profile-id (:id profile) + :project-id (:id priv-project) + :is-shared false + :name "LIbrary"})]) + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import in team 2 (library exists but not shared) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:id team2)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; No linking (library not shared) + (t/is (= {} resolution))))) + +(t/deftest link-later-cross-team-library-deleted + (let [{:keys [profile file-id]} (import-sample-file) + team2 (th/create-team* 2 {:profile-id (:id profile)}) + library2 (create-named-library th/*system* 10 (:id team2) "LIbrary") + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete the library in team 2 + (db/update! th/*system* :file + {:deleted-at (ct/now)} + {:id (:id library2)}) + + ;; Import in team 2 (library deleted) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:id team2)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; No linking (library deleted) + (t/is (= {} resolution))))) + +;; ----------------------------------------------------------------------------- +;; Category 3: Multiple Libraries +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-multiple-libraries-both-match + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + ;; Create two libraries + lib1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + ;; Create file linked to both + file (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; Link file to both libraries + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib1)}) + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib2)}) + + ;; Export with link-later + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original libraries + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib1)}) + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib2)}) + + ;; Create new libraries with same names + (let [lib1b (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib2b (th/create-file* 11 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Both libraries should be auto-linked + (t/is (some? file-res)) + (t/is (= 2 (count (:done file-res)))) + (t/is (= [] (:pending file-res))) + + ;; Verify both linked-to ids + (let [linked-ids (set (map :linked-to (:done file-res)))] + (t/is (contains? linked-ids (:id lib1b))) + (t/is (contains? linked-ids (:id lib2b)))))))) + +(t/deftest link-later-multiple-libraries-one-matches + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + file (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib1)}) + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib2)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete both libraries + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib1)}) + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib2)}) + + ;; Only recreate Icons (not Colors) + (let [lib1b (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Only Icons should be auto-linked + (t/is (some? file-res)) + (t/is (= 1 (count (:done file-res)))) + (t/is (= (:id lib1b) (:linked-to (first (:done file-res))))))))) + +(t/deftest link-later-multiple-libraries-both-multi-match + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + file (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib1)}) + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib2)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original libraries + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib1)}) + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib2)}) + + ;; Create TWO of each library (multi-match) + (let [_ (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + _ (th/create-file* 11 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + _ (th/create-file* 12 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + _ (th/create-file* 13 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Both libraries should be pending (multi-match) + (t/is (some? file-res)) + (t/is (= [] (:done file-res))) + (t/is (= 2 (count (:pending file-res)))) + + ;; Each pending should have 2 candidates + (doseq [pending-entry (:pending file-res)] + (t/is (= 2 (count (:candidates pending-entry))))))))) + +(t/deftest link-later-multiple-libraries-mixed-single-and-multi + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + file (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib1)}) + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib2)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original libraries + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib1)}) + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib2)}) + + ;; Create ONE Icons (single match) and TWO Colors (multi-match) + (let [lib1b (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + _ (th/create-file* 11 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + _ (th/create-file* 12 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Icons done, Colors pending + (t/is (some? file-res)) + (t/is (= 1 (count (:done file-res)))) + (t/is (= (:id lib1b) (:linked-to (first (:done file-res))))) + (t/is (= 1 (count (:pending file-res)))) + (t/is (= 2 (count (:candidates (first (:pending file-res)))))))))) + +;; ----------------------------------------------------------------------------- +;; Category 4: Multiple Files +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-multiple-files-same-library + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + file1 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + file2 (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; Both files use the library + (db/insert! th/*system* :file-library-rel + {:file-id (:id file1) :library-file-id (:id lib)}) + (db/insert! th/*system* :file-library-rel + {:file-id (:id file2) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file1) (:id file2)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original library and recreate + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) + (let [lib-b (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; Both files should have auto-linked + (t/is (= 2 (count (keys resolution)))) + (doseq [[file-id file-res] resolution] + (t/is (= 1 (count (:done file-res)))) + (t/is (= (:id lib-b) (:linked-to (first (:done file-res)))))))))) + +(t/deftest link-later-multiple-files-only-one-uses-library + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + file1 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + file2 (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; Only file1 uses the library + (db/insert! th/*system* :file-library-rel + {:file-id (:id file1) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file1) (:id file2)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original library and recreate + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) + (let [lib-b (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; Only file1 should have auto-linked + (let [files-with-done (filter #(seq (:done (val %))) resolution)] + (t/is (= 1 (count files-with-done))) + (let [[file-id file-res] (first files-with-done)] + (t/is (= (:id lib-b) (:linked-to (first (:done file-res))))))))))) + +(t/deftest link-later-multiple-files-different-libraries + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib-icons (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib-colors (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + file1 (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + file2 (th/create-file* 4 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; file1 uses Icons, file2 uses Colors + (db/insert! th/*system* :file-library-rel + {:file-id (:id file1) :library-file-id (:id lib-icons)}) + (db/insert! th/*system* :file-library-rel + {:file-id (:id file2) :library-file-id (:id lib-colors)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file1) (:id file2)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original libraries and recreate + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib-icons)}) + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib-colors)}) + (let [lib-icons-b (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib-colors-b (th/create-file* 11 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; Each file should have its respective library + (t/is (= 2 (count (keys resolution)))) + (doseq [[file-id file-res] resolution] + (t/is (= 1 (count (:done file-res)))) + (let [linked-id (:linked-to (first (:done file-res)))] + (t/is (or (= linked-id (:id lib-icons-b)) + (= linked-id (:id lib-colors-b)))))))))) + +;; ----------------------------------------------------------------------------- +;; Category 5: Permission Scenarios +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-permission-viewer-cannot-link + (let [owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + viewer (th/create-profile* 2) + _ (th/create-team-role* {:team-id (:id team) + :profile-id (:id viewer) + :role :viewer}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + lib (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project) + :is-shared true + :name "Icons Library"}) + file (th/create-file* 2 {:profile-id (:id owner) + :project-id (:id project) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import as viewer (no edit permission on library) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:id project)) + (assoc ::bfc/profile-id (:id viewer)) + (assoc ::bfc/team-id (:id team)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; No auto-link (viewer lacks edit permission) + (t/is (or (nil? file-res) + (= [] (:done file-res)))))))) + +(t/deftest link-later-permission-editor-can-link + (let [owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + editor (th/create-profile* 2) + _ (th/create-team-role* {:team-id (:id team) + :profile-id (:id editor) + :role :editor}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + lib (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project) + :is-shared true + :name "Icons Library"}) + file (th/create-file* 2 {:profile-id (:id owner) + :project-id (:id project) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import as editor (has edit permission) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:id project)) + (assoc ::bfc/profile-id (:id editor)) + (assoc ::bfc/team-id (:id team)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Auto-link should succeed + (t/is (some? file-res)) + (t/is (= 1 (count (:done file-res)))))))) + +;; ----------------------------------------------------------------------------- +;; Category 6: Edge Cases +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-edge-special-chars-in-name + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons & Buttons!"}) + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Verify slug in manifest + (let [manifest (v3/get-manifest output) + ext-lib (first (:external-libraries manifest))] + (t/is (= "icons-buttons" (:slug ext-lib))))))) + +(t/deftest link-later-edge-empty-slug-library + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + ;; Library name that slugifies to empty + lib (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "---"}) + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Library with empty slug should be dropped from external-libraries + (let [manifest (v3/get-manifest output) + ext-libs (:external-libraries manifest)] + (t/is (or (nil? ext-libs) + (empty? ext-libs))))))) + +(t/deftest link-later-edge-file-without-libraries + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with no libraries + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Manifest should have no external-libraries + (let [manifest (v3/get-manifest output)] + (t/is (nil? (:external-libraries manifest)))) + + ;; Import should succeed with empty resolution + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/input output) + (v3/import-files!))] + (t/is (= {} (:resolution result)))))) + +(t/deftest link-later-edge-case-insensitive-match + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "ICONS Library"}) + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original and create with different case + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) + (let [lib-b (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "icons library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Should match (slug is lowercase) + (t/is (some? file-res)) + (t/is (= 1 (count (:done file-res)))) + (t/is (= (:id lib-b) (:linked-to (first (:done file-res))))))))) + +;; ----------------------------------------------------------------------------- +;; Category 7: Reference Integrity +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-reference-integrity-component-file-remapped + (let [{:keys [profile file-id team-id file]} (import-sample-file) + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Re-import in same team + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + new-file-id (first (:file-ids result)) + resolution (:resolution result) + file-res (get resolution new-file-id)] + + ;; Verify auto-link happened + (t/is (= 1 (count (:done file-res)))) + (let [linked-lib-id (:linked-to (first (:done file-res)))] + ;; Get the imported file's data + (let [imported (:result (th/command! {::th/type :get-file + ::rpc/profile-id (:id profile) + :id new-file-id + :components-v2 true})) + shapes (get-file-shapes (:data imported))] + ;; Check that component-file references point to the new library + (doseq [shape shapes] + (when (contains? shape :component-file) + (t/is (= linked-lib-id (:component-file shape)) + "component-file should reference the linked library")))))))) + +(t/deftest link-later-reference-integrity-no-match-dangling-refs + (let [{:keys [profile file-id]} (import-sample-file) + team2 (th/create-team* 2 {:profile-id (:id profile)}) + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import in team 2 (no library exists) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:id team2)) + (assoc ::bfc/input output) + (v3/import-files!)) + new-file-id (first (:file-ids result))] + + ;; Get the original library id from the manifest + (let [manifest (v3/get-manifest output) + original-lib-id (:id (first (:external-libraries manifest)))] + ;; Get the imported file's data + (let [imported (:result (th/command! {::th/type :get-file + ::rpc/profile-id (:id profile) + :id new-file-id + :components-v2 true})) + file-data (:data imported) + pages (vals (:pages-index file-data)) + all-shapes (mapcat vals (map :objects pages)) + shapes-with-refs (filter #(contains? % :component-file) all-shapes)] + ;; component-file refs should remain as original (dangling) + (t/is (seq shapes-with-refs) "expected shapes with component-file refs") + (doseq [shape shapes-with-refs] + (t/is (= original-lib-id (:component-file shape)) + "component-file should remain as original UUID when no match"))))))) + +;; ----------------------------------------------------------------------------- +;; Category 8: Resolution Structure Verification +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-resolution-structure-single-file-single-lib + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete and recreate library + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) + (let [lib-b (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Verify exact structure + (t/is (map? file-res)) + (t/is (= new-file-id (:id file-res))) + (t/is (some? (:name file-res))) + (t/is (vector? (:done file-res))) + (t/is (vector? (:pending file-res))) + (t/is (= 1 (count (:done file-res)))) + + ;; Verify done entry structure + (let [done-entry (first (:done file-res))] + (t/is (contains? done-entry :id)) + (t/is (contains? done-entry :name)) + (t/is (contains? done-entry :linked-to)) + (t/is (= (:id lib) (:id done-entry))) + (t/is (= "Icons Library" (:name done-entry))) + (t/is (= (:id lib-b) (:linked-to done-entry)))))))) + +(t/deftest link-later-resolution-structure-multi-match + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete and create two libraries with same name + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) + (let [_ (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + _ (th/create-file* 11 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Verify pending structure + (t/is (= [] (:done file-res))) + (t/is (= 1 (count (:pending file-res)))) + + (let [pending-entry (first (:pending file-res))] + (t/is (contains? pending-entry :id)) + (t/is (contains? pending-entry :name)) + (t/is (contains? pending-entry :candidates)) + (t/is (= (:id lib) (:id pending-entry))) + (t/is (= "Icons Library" (:name pending-entry))) + (t/is (= 2 (count (:candidates pending-entry)))) + + ;; Verify candidate structure + (doseq [candidate (:candidates pending-entry)] + (t/is (contains? candidate :id)) + (t/is (contains? candidate :name)) + (t/is (contains? candidate :project-id)) + (t/is (contains? candidate :project-name)))))))) + +;; ----------------------------------------------------------------------------- +;; Code Review Regression Tests: Reference Integrity Bug Fix +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-multi-match-leaves-refs-dangling + (let [{:keys [profile file-id team-id library-id]} (import-sample-file) + output (tmp/tempfile :suffix ".zip")] + + ;; Create a SECOND library with the same name in the same team + (create-named-library th/*system* 2 team-id "LIbrary") + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Re-import in same team + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + new-file-id (first (:file-ids result)) + resolution (:resolution result) + file-res (get resolution new-file-id)] + + ;; Multi-match should produce pending candidates, not auto-link + (t/is (seq (:pending file-res)) + "multi-match should produce pending candidates") + (t/is (= [] (:done file-res)) + "multi-match should NOT auto-link") + + ;; Refs must remain as original UUID (dangling), NOT remapped to any + ;; of the candidate libraries + (let [manifest (v3/get-manifest output) + original-lib-id (:id (first (:external-libraries manifest))) + imported (:result (th/command! {::th/type :get-file + ::rpc/profile-id (:id profile) + :id new-file-id + :components-v2 true})) + file-data (:data imported) + pages (vals (:pages-index file-data)) + all-shapes (mapcat vals (map :objects pages)) + shapes-with-refs (filter #(contains? % :component-file) all-shapes)] + (t/is (seq shapes-with-refs) "expected shapes with component-file refs") + ;; The key assertion: refs should NOT be remapped to any candidate + ;; (they should remain as the original UUID from the manifest) + (let [slug (-> manifest :external-libraries first :slug) + matching (into #{} (map :id (bfc/find-shared-files-by-slug th/*system* team-id slug))) + candidate-ids (disj matching original-lib-id)] + (doseq [shape shapes-with-refs] + (t/is (not (contains? candidate-ids (:component-file shape))) + "component-file must NOT be remapped to any candidate library") + (t/is (= original-lib-id (:component-file shape)) + "component-file must remain as original UUID on multi-match"))))))) + +(t/deftest link-later-no-edit-permission-leaves-refs-dangling + (let [{:keys [profile file-id team-id library-id]} (import-sample-file) + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Create a viewer profile (no edit permission on the library) + (let [viewer (th/create-profile* th/*system* 2 {}) + _ (th/create-team-role* {:team-id team-id + :profile-id (:id viewer) + :role :viewer})] + + ;; Import as viewer + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id viewer)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + new-file-id (first (:file-ids result)) + resolution (:resolution result) + file-res (get resolution new-file-id)] + + ;; Viewer lacks edit permission, so no auto-link + (t/is (or (nil? file-res) + (= [] (:done file-res))) + "viewer should NOT auto-link") + + ;; Refs must remain as original UUID (dangling) + (let [manifest (v3/get-manifest output) + original-lib-id (:id (first (:external-libraries manifest))) + imported (:result (th/command! {::th/type :get-file + ::rpc/profile-id (:id viewer) + :id new-file-id + :components-v2 true})) + file-data (:data imported) + pages (vals (:pages-index file-data)) + all-shapes (mapcat vals (map :objects pages)) + shapes-with-refs (filter #(contains? % :component-file) all-shapes)] + (t/is (seq shapes-with-refs) "expected shapes with component-file refs") + (doseq [shape shapes-with-refs] + (t/is (= original-lib-id (:component-file shape)) + "component-file must remain as original UUID when viewer has no edit permission"))))))) + +(t/deftest export-type-takes-precedence-over-legacy-boolean + (let [{:keys [file-id]} (import-sample-file) + output (tmp/tempfile :suffix ".zip")] + + ;; Call export with BOTH type=:link-later AND include-libraries=true + ;; type should win + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/include-libraries true)) + (io/output-stream output)) + + ;; Verify manifest has external-libraries (only produced by link-later) + (let [manifest (v3/get-manifest output) + ext-libs (:external-libraries manifest)] + (t/is (some? ext-libs) + "type :link-later should produce external-libraries even with include-libraries=true") + (t/is (pos? (count ext-libs)))))) + +(t/deftest import-rejects-too-many-zip-entries + ;; import must reject ZIP files exceeding max-zip-entries + (let [profile (th/create-profile* 1) + file (prepare-simple-file profile) + output (tmp/tempfile :suffix ".zip")] + + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :detach-libraries)) + (io/output-stream output)) + + ;; Import with max-zip-entries=1 — the exported ZIP has more entries + (let [cfg (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/input output) + (assoc ::bfc/import-max-zip-entries 1)) + out (try + (v3/import-files! cfg) + :no-error + (catch Throwable e + (let [d (or (ex-data e) (some-> (ex-cause e) ex-data))] + d)))] + (t/is (= :validation (:type out))) + (t/is (= :too-many-zip-entries (:code out)))))) + +(defn- prepare-file-with-media + "Creates a file with a media object backed by a real storage object, + so that v3 export produces objects/ entries." + [profile] + (let [storage (-> (:app.storage/storage th/*system*) + (stt/configure-storage-backend)) + + sobject (sto/put-object! storage {::sto/content (sto/content "media-bytes") + :content-type "image/svg+xml" + :bucket "file-media-object"}) + + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + + mobj (th/create-file-media-object* {:file-id (:id file) + :is-local true + :media-id (:id sobject)})] + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :vern 0 + :changes + [{:type :add-media + :object mobj}]) + + (dissoc file :data))) + +(t/deftest import-rejects-oversized-object + ;; import must reject storage objects exceeding max-object-size + (let [profile (th/create-profile* 1) + file (prepare-file-with-media profile) + output (tmp/tempfile :suffix ".zip")] + + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :detach-libraries)) + (io/output-stream output)) + + ;; Import with max-object-size=1 — the media object will exceed this + (let [cfg (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/input output) + (assoc ::bfc/import-max-object-size 1)) + out (try + (v3/import-files! cfg) + :no-error + (catch Throwable e + (let [d (or (ex-data e) (some-> (ex-cause e) ex-data))] + d)))] + (t/is (= :validation (:type out))) + (t/is (= :max-file-size-reached (:code out)))))) diff --git a/backend/test/backend_tests/bounce_handling_test.clj b/backend/test/backend_tests/bounce_handling_test.clj index 9ae3d9a60d..37a339917e 100644 --- a/backend/test/backend_tests/bounce_handling_test.clj +++ b/backend/test/backend_tests/bounce_handling_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.bounce-handling-test (:require diff --git a/backend/test/backend_tests/db_test.clj b/backend/test/backend_tests/db_test.clj index 3d228bc8c1..39388dd825 100644 --- a/backend/test/backend_tests/db_test.clj +++ b/backend/test/backend_tests/db_test.clj @@ -2,10 +2,11 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.db-test (:require + [app.common.uuid :as uuid] [app.db :as db] [backend-tests.helpers :as th] [clojure.test :as t]) @@ -41,3 +42,13 @@ (t/testing "maximum pool size is reasonable" (t/is (pos? (:maximum-pool-size stats)))))) + +(t/deftest uuid->hash-code-is-deterministic + (t/is (= (db/uuid->hash-code uuid/zero) + (db/uuid->hash-code uuid/zero)))) + +(t/deftest uuid->hash-code-returns-long + (t/is (instance? Long (db/uuid->hash-code uuid/zero)))) + +(t/deftest uuid->hash-code-stable-for-zero-uuid + (t/is (= 3659997967308761462 (db/uuid->hash-code uuid/zero)))) diff --git a/backend/test/backend_tests/demo_test.clj b/backend/test/backend_tests/demo_test.clj new file mode 100644 index 0000000000..da1cc342c9 --- /dev/null +++ b/backend/test/backend_tests/demo_test.clj @@ -0,0 +1,47 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.demo-test + (:require + [app.common.time :as ct] + [app.db :as db] + [app.rpc.commands.profile :as profile] + [app.tasks.demo-purge :as demo-purge] + [app.worker :as wrk] + [backend-tests.helpers :as th] + [clojure.test :as t] + [integrant.core :as ig])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(t/deftest demo-profile-created-without-deleted-at + (let [profile (th/create-profile* 999 {:is-demo true})] + (t/is (true? (:is-demo profile))) + (t/is (nil? (:deleted-at profile))) + (t/is (some? (:id profile))))) + +(t/deftest get-profile-finds-demo-user-without-override + (let [profile (th/create-profile* 998 {:is-demo true}) + found (db/run! th/*pool* + (fn [{:keys [::db/conn]}] + (profile/get-profile conn (:id profile))))] + (t/is (some? found)) + (t/is (= (:id profile) (:id found))))) + +(t/deftest demo-purge-handler-submits-delete-object + (let [profile (th/create-profile* 996 {:is-demo true}) + handler (ig/init-key :app.tasks.demo-purge/handler + {::db/pool th/*pool*}) + submitted (atom nil)] + (with-redefs [wrk/submit! (fn [& {:keys [::wrk/task ::wrk/params]}] + (reset! submitted {:task task :params params}))] + (handler {:props {:profile-id (:id profile) + :deleted-at (ct/now)}})) + (t/is (= :delete-object (:task @submitted))) + (t/is (= :profile (:object (:params @submitted)))) + (t/is (= (:id profile) (:id (:params @submitted)))) + (t/is (some? (:deleted-at (:params @submitted)))))) diff --git a/backend/test/backend_tests/email_blacklist_test.clj b/backend/test/backend_tests/email_blacklist_test.clj index 80c3bb4562..632302ab84 100644 --- a/backend/test/backend_tests/email_blacklist_test.clj +++ b/backend/test/backend_tests/email_blacklist_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.email-blacklist-test (:require diff --git a/backend/test/backend_tests/email_sending_test.clj b/backend/test/backend_tests/email_sending_test.clj index 91d2848185..55d1ab5e27 100644 --- a/backend/test/backend_tests/email_sending_test.clj +++ b/backend/test/backend_tests/email_sending_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.email-sending-test (:require diff --git a/backend/test/backend_tests/graph_binder_gate_test.clj b/backend/test/backend_tests/graph_binder_gate_test.clj new file mode 100644 index 0000000000..508bf9d60d --- /dev/null +++ b/backend/test/backend_tests/graph_binder_gate_test.clj @@ -0,0 +1,165 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.graph-binder-gate-test + "Binder gate for the incremental-sync statement templates. + + Every template `app.graph.sync` emits is *prepared* — parsed and bound by + the engine against the live DDL — and never executed. A parse or bind + failure (a renamed column, a reserved-word label emitted unquoted, a dropped + table) turns the gate red here, before the statement can reach a live + session. + + One instance per template is the gate; per-column type coverage belongs to + beadpot's schema diff, not here. The templates are `defn-`, so they are + reached through their vars." + (:require + [app.graph.ladybug :as ladybug] + [app.graph.schema.nodes :as nodes] + [app.graph.sync] + [clojure.test :as t])) + +(def ^:private create-node-statement #'app.graph.sync/create-node-statement) +(def ^:private delete-node-statement #'app.graph.sync/delete-node-statement) +(def ^:private create-edge-statement #'app.graph.sync/create-edge-statement) +(def ^:private delete-edge-statement #'app.graph.sync/delete-edge-statement) +(def ^:private set-edge-position-statement #'app.graph.sync/set-edge-position-statement) +(def ^:private create-instance-of-statement #'app.graph.sync/create-instance-of-statement) +(def ^:private delete-instance-of-statement #'app.graph.sync/delete-instance-of-statement) +(def ^:private set-node-attr-statement #'app.graph.sync/set-node-attr-statement) +(def ^:private set-page-name-statement #'app.graph.sync/set-page-name-statement) +(def ^:private remove-node-attr-statement #'app.graph.sync/remove-node-attr-statement) +(def ^:private set-document-revision-statement #'app.graph.sync/set-document-revision-statement) + +;; Dummy identities. Fixed rather than generated: a gate failure should read +;; the same on every run. +(def ^:private doc-id #uuid "00000000-0000-0000-0000-0000000000d0") +(def ^:private page-id #uuid "00000000-0000-0000-0000-0000000000a0") +(def ^:private shape-id #uuid "00000000-0000-0000-0000-0000000000b0") +(def ^:private frame-id #uuid "00000000-0000-0000-0000-0000000000c0") +(def ^:private component-id #uuid "00000000-0000-0000-0000-0000000000e0") + +(def ^:private child-edge + {:from-table "Rectangle" :from-id shape-id + :to-table "Page" :to-id page-id + :position 3}) + +(def ^:private ^:dynamic *conn* nil) + +(defn- with-graph-connection + "Open a `:memory:` database, create the live schema, run the tests on it. + + Nothing is executed against it — the gate only prepares — but the DDL has to + be there for the binder to resolve tables and columns against." + [next] + (ladybug/with-connection! ":memory:" + (fn [conn] + (ladybug/exec-on-connection! conn (nodes/ddl-statements)) + (binding [*conn* conn] + (next))))) + +(t/use-fixtures :once with-graph-connection) + +(defn- gate + "Assert `statement` binds, and that the engine agrees on read/write." + [label statement read-only?] + (let [result (ladybug/validate-on-connection! *conn* statement)] + (t/is (:ok? result) + (str label " does not bind: " (:error result) "\n " statement)) + (when (:ok? result) + (t/is (= read-only? (:read-only? result)) + (str label " read-only? " (:read-only? result) ", expected " read-only?))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; the eleven sync templates +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest create-node-binds + (gate "create-node-statement" + (create-node-statement "Rectangle" {:id shape-id + :name "a shape" + :opacity 1.0 + :hidden false}) + false)) + +(t/deftest delete-node-binds + (gate "delete-node-statement" + (delete-node-statement "Rectangle" shape-id) + false)) + +(t/deftest create-edge-binds + (gate "create-edge-statement" + (create-edge-statement child-edge) + false)) + +(t/deftest delete-edge-binds + (gate "delete-edge-statement" + (delete-edge-statement (dissoc child-edge :position)) + false)) + +(t/deftest set-edge-position-binds + (gate "set-edge-position-statement" + (set-edge-position-statement child-edge) + false)) + +(t/deftest create-instance-of-binds + (gate "create-instance-of-statement" + (create-instance-of-statement frame-id component-id) + false)) + +(t/deftest delete-instance-of-binds + (gate "delete-instance-of-statement" + (delete-instance-of-statement frame-id) + false)) + +(t/deftest set-node-attr-binds + (gate "set-node-attr-statement" + (set-node-attr-statement "Rectangle" shape-id :name "a shape") + false)) + +(t/deftest set-page-name-binds + (gate "set-page-name-statement" + (set-page-name-statement page-id "a page") + false)) + +(t/deftest remove-node-attr-binds + (gate "remove-node-attr-statement" + (remove-node-attr-statement "Rectangle" shape-id :name) + false)) + +(t/deftest set-document-revision-binds + (gate "set-document-revision-statement" + (set-document-revision-statement doc-id 42) + false)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; label quoting across the registry +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest every-node-label-binds + ;; `Group` and `Boolean` are reserved words: unquoted they do not parse. + ;; One MATCH per registered table is the cheapest way to keep `match-label` + ;; honest as tables come and go. + (doseq [table (map :table nodes/node-types)] + (gate (str "delete-node-statement on " table) + (delete-node-statement table shape-id) + false))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; the gate itself +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest read-only-discriminates + ;; Without this the `read-only? false` assertions above would hold for a + ;; `validate-on-connection!` that always answered false. + (gate "a read query" "MATCH (n:Rectangle) RETURN count(n);" true)) + +(t/deftest bad-statement-is-reported-not-thrown + (let [result (ladybug/validate-on-connection! + *conn* "MATCH (n:Rectangle) SET n.no_such_column = 1;")] + (t/is (false? (:ok? result))) + (t/is (string? (:error result))) + (t/is (nil? (:read-only? result))))) diff --git a/backend/test/backend_tests/graph_sync_parity_test.clj b/backend/test/backend_tests/graph_sync_parity_test.clj new file mode 100644 index 0000000000..c7eff4c34e --- /dev/null +++ b/backend/test/backend_tests/graph_sync_parity_test.clj @@ -0,0 +1,280 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.graph-sync-parity-test + "Cold projection and incremental sync are two implementations of one mapping, + and this namespace holds them to it. + + `app.graph.projection.document/projection-data` reads a whole file and produces + the whole graph. `app.graph.sync/apply-changes!` takes the change vocabulary + the editor emits and mutates an already open graph. A graph the second one + maintained must equal a graph the first one would build from the same file, + or the console shows a graph no rebuild reproduces. + + The round trip: project a file cold into A, apply a change list to A and the + same list to the file data, project the resulting data cold into B, and diff + A against B. Two `:memory:` databases, no Postgres, no session." + (:require + [app.common.features :as ffeat] + [app.common.files.changes :as cfc] + [app.common.time :as ct] + [app.common.types.file :as ctf] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [app.graph.arrow :as arrow] + [app.graph.ladybug :as ladybug] + [app.graph.projection.document :as projection.document] + [app.graph.projection.transforms :as projection.transforms] + [app.graph.schema.nodes :as nodes] + [app.graph.sync :as sync] + [clojure.test :as t])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; the fixture file +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; Fixed ids: a failure should read the same on every run. +(def ^:private file-id #uuid "00000000-0000-0000-0000-00000000f11e") +(def ^:private page-id #uuid "00000000-0000-0000-0000-0000000000a1") +(def ^:private page2-id #uuid "00000000-0000-0000-0000-0000000000a2") +(def ^:private frame-id #uuid "00000000-0000-0000-0000-0000000000f1") +(def ^:private rect-id #uuid "00000000-0000-0000-0000-0000000000b1") +(def ^:private circ-id #uuid "00000000-0000-0000-0000-0000000000b2") +(def ^:private text-id #uuid "00000000-0000-0000-0000-0000000000b3") +(def ^:private rect2-id #uuid "00000000-0000-0000-0000-0000000000b4") + +(def ^:private base-revn 1) + +(defn- file-row + "The `file` map the projection reads, as `bfc/get-file` returns it minus the + data blob." + [revn] + {:id file-id + :name "graph sync parity fixture" + :revn revn + :version 70 + :features #{"components/v2"} + :created-at (ct/inst "2026-01-01T00:00:00Z") + :modified-at (ct/inst "2026-01-02T00:00:00Z")}) + +(defn- base-data + [] + (binding [ffeat/*current* #{"components/v2"}] + (ctf/make-file-data file-id page-id))) + +(defn- shape + [id type attrs] + (cts/setup-shape (merge {:id id + :type type + :frame-id uuid/zero + :parent-id uuid/zero} + attrs))) + +(def ^:private changes + "One change of every kind the sync path claims to support that this fixture + can exercise, in the order an editing session would emit them. + + Four siblings in one container, then a reorder, a reparent, and a delete: + sibling order is where the two paths are easiest to get wrong, because the + stored `:shapes` list and `IsChildOf.position` run opposite ways." + [{:type :add-obj :page-id page-id :id frame-id + :parent-id uuid/zero :frame-id uuid/zero + :obj (shape frame-id :frame {:name "Board" :width 400 :height 300})} + + {:type :add-obj :page-id page-id :id rect-id + :parent-id frame-id :frame-id frame-id + :obj (shape rect-id :rect {:name "Rect" :parent-id frame-id :frame-id frame-id + :width 100 :height 50})} + + {:type :add-obj :page-id page-id :id circ-id + :parent-id frame-id :frame-id frame-id + :obj (shape circ-id :circle {:name "Circle" :parent-id frame-id :frame-id frame-id + :width 40 :height 40})} + + {:type :add-obj :page-id page-id :id text-id + :parent-id frame-id :frame-id frame-id + :obj (shape text-id :text {:name "Label" :parent-id frame-id :frame-id frame-id})} + + {:type :add-obj :page-id page-id :id rect2-id + :parent-id frame-id :frame-id frame-id + :obj (shape rect2-id :rect {:name "Rect two" :parent-id frame-id :frame-id frame-id + :width 20 :height 20})} + + ;; A rename, and two attributes whose values are falsy: `blocked false` and + ;; `opacity 0` are values, not absences, on both paths. + {:type :mod-obj :page-id page-id :id rect-id + :operations [{:type :set :attr :name :val "Renamed rect"} + {:type :set :attr :blocked :val false} + {:type :set :attr :opacity :val 0}]} + + ;; Reorder inside the same container: the edge keeps its endpoints and + ;; every sibling it passes has to move. + {:type :mov-objects :page-id page-id :parent-id frame-id :index 0 :shapes [circ-id]} + + ;; Reparent to the page's root frame: the edge moves, and so do the + ;; shape's own `parent_id` and `frame_id`. + {:type :mov-objects :page-id page-id :parent-id uuid/zero :index 0 :shapes [text-id]} + + ;; Delete with survivors: the gap in the sibling numbering has to close. + {:type :del-obj :page-id page-id :id rect-id} + + {:type :add-page :id page2-id :name "Page two"} + {:type :mod-page :id page-id :name "Page one, renamed"}]) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; projecting and reading back +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- load-graph! + "Create the schema on `conn`, project `data` into it, run the transforms. + + Returns the projection, which is also what the sync index is built from." + [conn data file] + (let [projection (projection.document/projection-data data file)] + (ladybug/exec-on-connection! conn (nodes/ddl-statements)) + (arrow/with-allocator! + (fn [allocator] (arrow/load-projection! conn projection allocator))) + (projection.transforms/apply-transforms! nil conn data file) + projection)) + +(defn- rel-tables + [conn] + (mapv first (:rows (ladybug/query-on-connection! + conn "CALL show_tables() WHERE type = 'REL' RETURN name;" + :max-rows 1000)))) + +(defn- rel-properties + "Property names on rel table `rel`, in catalog order." + [conn rel] + (mapv (comp str second) + (:rows (ladybug/query-on-connection! + conn (str "CALL table_info('" rel "') RETURN *;") + :max-rows 1000)))) + +(defn- node-rows + [conn table] + (:rows (ladybug/query-on-connection! + conn (str "MATCH (n:" (nodes/match-label table) ") RETURN n.* ORDER BY n.id;") + :max-rows 100000))) + +(defn- edge-rows + [conn rel props] + (let [returns (into ["a.id" "b.id"] (map #(str "r.`" % "`")) props)] + (:rows (ladybug/query-on-connection! + conn (str "MATCH (a)-[r:`" rel "`]->(b) " + "RETURN " (clojure.string/join ", " returns) " " + "ORDER BY a.id, b.id;") + :max-rows 100000)))) + +(defn- keyed-rows + "Rows as `{key {column value}}`, so a difference names a row and a column. + + Values are stringified: both connections hand a value back through the same + reader, so any difference in the strings is a difference in the graph." + [columns key-columns rows] + (into {} + (map (fn [row] + (let [cells (zipmap columns (map str row))] + [(mapv cells key-columns) cells]))) + rows)) + +(defn- snapshot + "Every node row and every edge row in the database, keyed by table." + [conn] + {:nodes (into {} + (map (fn [{:keys [table]}] + (let [columns (nodes/columns table)] + [table (keyed-rows columns ["id"] (node-rows conn table))]))) + nodes/node-types) + :edges (into {} + (map (fn [rel] + (let [columns (into ["from" "to"] (rel-properties conn rel))] + [rel (keyed-rows columns ["from" "to"] + (edge-rows conn rel (rel-properties conn rel)))]))) + (rel-tables conn))}) + +(defn- row-diff + [rows-a rows-b] + (into {} + (for [k (sort (into #{} (concat (keys rows-a) (keys rows-b)))) + :let [a (get rows-a k) + b (get rows-b k)] + :when (not= a b)] + [k (cond + (nil? a) {:only-in :rebuilt} + (nil? b) {:only-in :synced} + :else (into {} + (for [c (sort (into #{} (concat (keys a) (keys b)))) + :when (not= (get a c) (get b c))] + [c {:synced (get a c) :rebuilt (get b c)}])))]))) + +(defn- diff + "Where the two snapshots disagree, down to the row and the column." + [a b] + (into {} + (for [kind [:nodes :edges] + table (sort (into #{} (concat (keys (get a kind)) (keys (get b kind))))) + :let [d (row-diff (get-in a [kind table]) (get-in b [kind table]))] + :when (seq d)] + [[kind table] d]))) + +(defn- with-two-connections + [f] + (ladybug/with-connection! ":memory:" + (fn [conn-a] + (ladybug/with-connection! ":memory:" + (fn [conn-b] + (f conn-a conn-b)))))) + +(defn- round-trip + "Sync `change-list` into A, rebuild the same file into B, return the diff." + [change-list] + (let [data0 (base-data) + data1 (cfc/process-changes data0 change-list) + revn1 (inc base-revn)] + (with-two-connections + (fn [conn-a conn-b] + (let [projection (load-graph! conn-a data0 (file-row base-revn)) + index (sync/build-index file-id base-revn projection) + result (sync/apply-changes! conn-a index change-list revn1)] + (load-graph! conn-b data1 (file-row revn1)) + {:diff (diff (snapshot conn-a) (snapshot conn-b)) + :applied (:applied result) + :skipped (:skipped result)}))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; the tests +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest every-change-in-the-list-is-supported + (let [{:keys [applied skipped]} (round-trip changes)] + (t/is (empty? skipped) + (str "the fixture must exercise the sync path, not the skip path: " (pr-str skipped))) + (t/is (= (count changes) (count applied))))) + +(t/deftest synced-graph-equals-rebuilt-graph + (let [{:keys [diff]} (round-trip changes)] + (t/is (empty? diff) + (str "cold projection and sync replay disagree on " + (pr-str (keys diff)) "\n" (pr-str diff))))) + +(t/deftest the-diff-catches-an-injected-sync-bug + ;; The round trip is only worth running if it fails when sync is wrong. + ;; `apply-mov-objects` maintains `IsChildOf`; drop the change from the list + ;; sync sees, keep it in the list the file sees, and the edge must differ. + (let [data0 (base-data) + data1 (cfc/process-changes data0 changes) + crippled (remove #(= :mov-objects (:type %)) changes) + revn1 (inc base-revn) + result (with-two-connections + (fn [conn-a conn-b] + (let [projection (load-graph! conn-a data0 (file-row base-revn)) + index (sync/build-index file-id base-revn projection)] + (sync/apply-changes! conn-a index crippled revn1) + (load-graph! conn-b data1 (file-row revn1)) + (diff (snapshot conn-a) (snapshot conn-b)))))] + (t/is (contains? result [:edges "IsChildOf"]) + "a sync that skips a reparent must show up as an IsChildOf difference"))) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index f839f222b9..d4d8935418 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.helpers (:require @@ -189,7 +189,7 @@ (let [params (merge {:id (mk-uuid "profile" i) :fullname (str "Profile " i) :email (str "profile" i ".test@nodomain.com") - :password "123123" + :password "Test123!" :is-demo false} params)] (db/run! system diff --git a/backend/test/backend_tests/http_assets_test.clj b/backend/test/backend_tests/http_assets_test.clj index 796306efe2..4c6198afd7 100644 --- a/backend/test/backend_tests/http_assets_test.clj +++ b/backend/test/backend_tests/http_assets_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.http-assets-test (:require @@ -13,6 +13,7 @@ [app.http.access-token :as actoken] [app.http.assets :as assets] [app.http.session :as session] + [app.rpc :as-alias rpc] [app.rpc.commands.access-token :as access-token] [app.storage :as sto] [backend-tests.helpers :as th] @@ -36,11 +37,16 @@ (assoc storage ::sto/backend :fs)) (defn- create-storage-object! - "Create a storage object with the given bucket and content." - [storage bucket content] - (sto/put-object! storage {::sto/content (sto/content content) - :bucket bucket - :content-type "text/plain"})) + "Create a storage object with the given bucket and content. + Optional opts map can include :profile-id to set the owner." + ([storage bucket content] + (create-storage-object! storage bucket content {})) + ([storage bucket content {:keys [profile-id]}] + (sto/put-object! storage (cond-> {::sto/content (sto/content content) + :bucket bucket + :content-type "text/plain"} + (some? profile-id) + (assoc :profile-id profile-id))))) (defn- make-handler-cfg "Build a minimal cfg map for the assets handlers." @@ -269,6 +275,50 @@ (t/is (string? redirect)) (t/is (clojure.string/includes? redirect (sto/object->relative-path object))))) +;; ---------------------------------------------------------------- +;; Tests: objects-handler — content disposition +;; ---------------------------------------------------------------- + +(t/deftest objects-handler-non-public-bucket-served-as-attachment + ;; A non-public bucket holds bytes the user uploaded and is reachable by + ;; direct navigation, so the response marks it as an attachment. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1)] + + (doseq [bucket ["profile" + "tempfile" + "file-data" + "file-thumbnail" + "file-change"]] + (t/testing (str "bucket: " bucket) + (let [object (create-storage-object! storage bucket "some data") + request {:path-params {:id (str (:id object))} + ::session/profile-id (:id profile)} + response (assets/objects-handler cfg request)] + (t/is (= "attachment" (get (::yres/headers response) "content-disposition")) + (str "bucket " bucket " should be served as an attachment"))))))) + +(t/deftest objects-handler-public-bucket-served-inline + ;; Public buckets are embedded by the viewer and by outgoing mail, so they + ;; keep being served without a disposition. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage)] + + (doseq [bucket ["file-media-object" + "file-object-thumbnail" + "team-font-variant" + "file-data-fragment" + "organization"]] + (t/testing (str "bucket: " bucket) + (let [object (create-storage-object! storage bucket "some data") + request {:path-params {:id (str (:id object))}} + response (assets/objects-handler cfg request)] + (t/is (nil? (get (::yres/headers response) "content-disposition")) + (str "bucket " bucket " should stay inline"))))))) + ;; ---------------------------------------------------------------- ;; Tests: objects-handler — cache headers ;; ---------------------------------------------------------------- @@ -459,6 +509,240 @@ ;; Tests: objects-handler — expired objects ;; ---------------------------------------------------------------- +;; ---------------------------------------------------------------- +;; Tests: file-objects-handler — authz required (T2-N1-01) +;; ---------------------------------------------------------------- + +(t/deftest file-objects-handler-unauthenticated-returns-404 + ;; Unauthenticated requests to file-media assets must return 404 + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project)}) + media-storage (create-storage-object! storage "file-media-object" "image data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id media-storage)}) + request {:path-params {:id (str (:id media-obj))}} + response (assets/file-objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest file-objects-handler-no-file-perms-returns-404 + ;; Authenticated user without file read permissions must get 404 + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + media-storage (create-storage-object! storage "file-media-object" "image data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id media-storage)}) + stranger (th/create-profile* 2) + request {:path-params {:id (str (:id media-obj))} + ::session/profile-id (:id stranger)} + response (assets/file-objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest file-objects-handler-with-file-perms-succeeds + ;; Authenticated user with file read permissions must get the object + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + media-storage (create-storage-object! storage "file-media-object" "image data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id media-storage)}) + request {:path-params {:id (str (:id media-obj))} + ::session/profile-id (:id owner)} + response (assets/file-objects-handler cfg request)] + (t/is (= 204 (::yres/status response))))) + +(t/deftest file-thumbnails-handler-unauthenticated-returns-404 + ;; Unauthenticated requests to file-thumbnail assets must return 404 + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project)}) + media-storage (create-storage-object! storage "file-media-object" "image data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id media-storage)}) + request {:path-params {:id (str (:id media-obj))}} + response (assets/file-thumbnails-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest file-thumbnails-handler-with-file-perms-succeeds + ;; Authenticated user with file read permissions must get the thumbnail + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + thumb-storage (create-storage-object! storage "file-object-thumbnail" "thumb data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id thumb-storage)}) + request {:path-params {:id (str (:id media-obj))} + ::session/profile-id (:id owner)} + response (assets/file-thumbnails-handler cfg request)] + ;; Falls back to media-id since no thumbnail-id, but still serves + (t/is (= 204 (::yres/status response))))) + +(t/deftest file-objects-handler-non-existent-media-returns-404 + ;; Request for non-existent file-media-object returns 404 + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1) + request {:path-params {:id (str (uuid/next))} + ::session/profile-id (:id profile)} + response (assets/file-objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest file-objects-handler-nil-profile-id-returns-404 + ;; When profile-id is nil (invalid session), must return 404 + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project)}) + media-storage (create-storage-object! storage "file-media-object" "image data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id media-storage)}) + request {:path-params {:id (str (:id media-obj))} + ::session/profile-id nil} + response (assets/file-objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +;; ---------------------------------------------------------------- +;; Tests: file-objects-handler — share-link authz (issue #11338) +;; ---------------------------------------------------------------- + +(t/deftest file-objects-handler-anonymous-with-valid-share-id-succeeds + ;; Anonymous request with a valid share-id matching the file must + ;; succeed (share-link viewers are unauthenticated by definition). + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + media-storage (create-storage-object! storage "file-media-object" "image data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id media-storage)}) + slink (:result (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{} + :who-comment "team" + :who-inspect "all"})) + request {:path-params {:id (str (:id media-obj))} + :query-params {:share-id (str (:id slink))}} + response (assets/file-objects-handler cfg request)] + (t/is (= 204 (::yres/status response))))) + +(t/deftest file-objects-handler-anonymous-with-share-id-for-other-file-returns-404 + ;; A share-id from file A must not grant access to assets of file B. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file-a (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + file-b (th/create-file* 2 {:profile-id (:id owner) + :project-id (:id project)}) + media-a (create-storage-object! storage "file-media-object" "image A") + media-obj-a (th/create-file-media-object* {:file-id (:id file-a) + :media-id (:id media-a)}) + media-b (create-storage-object! storage "file-media-object" "image B") + media-obj-b (th/create-file-media-object* {:file-id (:id file-b) + :media-id (:id media-b)}) + slink (:result (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file-a) + :pages #{} + :who-comment "team" + :who-inspect "all"})) + request {:path-params {:id (str (:id media-obj-b))} + :query-params {:share-id (str (:id slink))}} + response (assets/file-objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest file-objects-handler-anonymous-with-malformed-share-id-returns-404 + ;; Malformed share-id must not raise; it must short-circuit to 404. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project)}) + media-storage (create-storage-object! storage "file-media-object" "image data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id media-storage)}) + request {:path-params {:id (str (:id media-obj))} + :query-params {:share-id "not-a-uuid"}} + response (assets/file-objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest file-thumbnails-handler-anonymous-with-valid-share-id-succeeds + ;; Thumbnail endpoint must also honor the share-id query param. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + thumb-storage (create-storage-object! storage "file-object-thumbnail" "thumb data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id thumb-storage)}) + slink (:result (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{} + :who-comment "team" + :who-inspect "all"})) + request {:path-params {:id (str (:id media-obj))} + :query-params {:share-id (str (:id slink))}} + response (assets/file-thumbnails-handler cfg request)] + ;; Falls back to media-id since no thumbnail-id, but still serves + (t/is (= 204 (::yres/status response))))) + (t/deftest objects-handler-expired-object ;; Expired objects should return 404 (get-object filters them out). (let [storage (-> (:app.storage/storage th/*system*) @@ -473,3 +757,70 @@ ::session/profile-id (:id profile)} response (assets/objects-handler cfg request)] (t/is (= 404 (::yres/status response))))) + +;; ---------------------------------------------------------------- +;; Tests: objects-handler — tempfile bucket ownership (T9-F-10) +;; ---------------------------------------------------------------- + +(t/deftest objects-handler-tempfile-owner-can-access + ;; Owner of a tempfile should be able to access it via session auth. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)}) + request {:path-params {:id (str (:id object))} + ::session/profile-id (:id owner)} + response (assets/objects-handler cfg request)] + (t/is (= 204 (::yres/status response))))) + +(t/deftest objects-handler-tempfile-non-owner-gets-404 + ;; Non-owner accessing a tempfile should get 404 (not 403, to avoid leaking existence). + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + stranger (th/create-profile* 2) + object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)}) + request {:path-params {:id (str (:id object))} + ::session/profile-id (:id stranger)} + response (assets/objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest objects-handler-tempfile-access-token-owner-can-access + ;; Owner of a tempfile should be able to access it via access token auth. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)}) + request {:path-params {:id (str (:id object))} + ::actoken/profile-id (:id owner)} + response (assets/objects-handler cfg request)] + (t/is (= 204 (::yres/status response))))) + +(t/deftest objects-handler-tempfile-access-token-non-owner-gets-404 + ;; Non-owner accessing a tempfile via access token should get 404. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + stranger (th/create-profile* 2) + object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)}) + request {:path-params {:id (str (:id object))} + ::actoken/profile-id (:id stranger)} + response (assets/objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest objects-handler-tempfile-no-stored-profile-id-serves + ;; Legacy tempfile objects without stored profile-id should be accessible + ;; to any authenticated user (backward compatibility). + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + stranger (th/create-profile* 1) + object (create-storage-object! storage "tempfile" "legacy temp data") + request {:path-params {:id (str (:id object))} + ::session/profile-id (:id stranger)} + response (assets/objects-handler cfg request)] + (t/is (= 204 (::yres/status response))))) diff --git a/backend/test/backend_tests/http_management_test.clj b/backend/test/backend_tests/http_management_test.clj index ba114a673a..15413eb5ce 100644 --- a/backend/test/backend_tests/http_management_test.clj +++ b/backend/test/backend_tests/http_management_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.http-management-test (:require diff --git a/backend/test/backend_tests/http_middleware_security.clj b/backend/test/backend_tests/http_middleware_security.clj index 3a4ecc9012..a7a79e97b0 100644 --- a/backend/test/backend_tests/http_middleware_security.clj +++ b/backend/test/backend_tests/http_middleware_security.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.http-middleware-security (:require diff --git a/backend/test/backend_tests/http_middleware_test.clj b/backend/test/backend_tests/http_middleware_test.clj index bd986fc031..6ec83924be 100644 --- a/backend/test/backend_tests/http_middleware_test.clj +++ b/backend/test/backend_tests/http_middleware_test.clj @@ -2,14 +2,16 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.http-middleware-test (:require + [app.common.exceptions :as ex] [app.common.time :as ct] [app.db :as db] [app.http :as-alias http] [app.http.access-token] + [app.http.errors :as http-errors] [app.http.middleware :as mw] [app.http.session :as session] [app.main :as-alias main] @@ -300,7 +302,7 @@ (t/is (instance? clojure.lang.ExceptionInfo ex)) (t/is (= :validation (-> ex ex-data :type))) (t/is (= :malformed-json (-> ex ex-data :code))) - (t/is (string? (-> ex ex-data :hint))))) + (t/is (= "invalid JSON in request body" (-> ex ex-data :hint))))) (t/deftest parse-request-request-too-big-exception ;; When RequestTooBigException is raised (e.g. the request body @@ -319,7 +321,7 @@ (t/is (instance? clojure.lang.ExceptionInfo ex)) (t/is (= :validation (-> ex ex-data :type))) (t/is (= :request-body-too-large (-> ex ex-data :code))) - (t/is (string? (-> ex ex-data :hint))))) + (t/is (= "request body exceeds size limit" (-> ex ex-data :hint))))) (t/deftest parse-request-eof-exception ;; When java.io.EOFException is raised (e.g. the body stream @@ -337,7 +339,7 @@ (t/is (instance? clojure.lang.ExceptionInfo ex)) (t/is (= :validation (-> ex ex-data :type))) (t/is (= :malformed-json (-> ex ex-data :code))) - (t/is (string? (-> ex ex-data :hint))))) + (t/is (= "unexpected end of request body" (-> ex ex-data :hint))))) (t/deftest parse-request-runtime-exception-with-cause ;; When a RuntimeException with a non-nil ex-cause is raised, @@ -377,7 +379,7 @@ (t/is (= 500 (::yres/status response))) (t/is (= :server-error (:type body))) (t/is (= :unexpected (:code body))) - (t/is (= "boom" (:hint body))))) + (t/is (nil? (:hint body))))) (t/deftest parse-request-non-runtime-throwable ;; When a non-RuntimeException Throwable is raised (e.g. an @@ -397,4 +399,45 @@ (t/is (= 500 (::yres/status response))) (t/is (= :server-error (:type body))) (t/is (= :io-exception (:code body))) - (t/is (= "network gone" (:hint body))))) + (t/is (nil? (:hint body))))) + +(t/deftest internal-error-strips-sensitive-fields + ;; When an :internal error is raised with :state, :path, and + ;; :context, those fields must not appear in the response body. + ;; :hint is part of the error protocol and is preserved. + (let [cause (ex-info "internal error" + {:type :internal + :code :test-error + :hint "safe user-facing hint" + :state "XX000" + :path "/data/penpot/storage" + :context {:backend :s3 :bucket "prod"}}) + response (http-errors/handle cause {}) + body (::yres/body response)] + (t/is (= 500 (::yres/status response))) + (t/is (= :server-error (:type body))) + (t/is (= :test-error (:code body))) + (t/is (= "safe user-facing hint" (:hint body))) + (t/is (nil? (:state body))) + (t/is (nil? (:path body))) + (t/is (nil? (:context body))))) + +(t/deftest unhandled-exinfo-strips-sensitive-fields + ;; When an ex-info with an unregistered :type (dispatches through + ;; handle-exception :default :else) carries :state and :path, + ;; those fields must not appear in the response body. + ;; :hint is part of the error protocol and is preserved. + (let [cause (ex-info "something broke" + {:type :unregistered-type + :code :custom-code + :hint "safe user-facing hint" + :state "internal-state" + :path "/internal/path"}) + response (http-errors/handle cause {}) + body (::yres/body response)] + (t/is (= 500 (::yres/status response))) + (t/is (= :server-error (:type body))) + (t/is (= :custom-code (:code body))) + (t/is (= "safe user-facing hint" (:hint body))) + (t/is (nil? (:state body))) + (t/is (nil? (:path body))))) diff --git a/backend/test/backend_tests/loggers_webhooks_test.clj b/backend/test/backend_tests/loggers_webhooks_test.clj index 59542040bb..ece038fef7 100644 --- a/backend/test/backend_tests/loggers_webhooks_test.clj +++ b/backend/test/backend_tests/loggers_webhooks_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.loggers-webhooks-test (:require diff --git a/backend/test/backend_tests/logical_deletion_test.clj b/backend/test/backend_tests/logical_deletion_test.clj index f8dd7e25a7..f9a43e0381 100644 --- a/backend/test/backend_tests/logical_deletion_test.clj +++ b/backend/test/backend_tests/logical_deletion_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.logical-deletion-test (:require diff --git a/backend/test/backend_tests/media_remote_test.clj b/backend/test/backend_tests/media_remote_test.clj new file mode 100644 index 0000000000..7cc1eae917 --- /dev/null +++ b/backend/test/backend_tests/media_remote_test.clj @@ -0,0 +1,593 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.media-remote-test + (:require + [app.common.exceptions :as ex] + [app.config :as cf] + [app.media.remote :as media.remote] + [app.setup :as-alias setup] + [app.util.json :as json] + [backend-tests.helpers :as th] + [clojure.test :as t] + [cuerdas.core :as str] + [datoteka.fs :as fs] + [datoteka.io :as io] + [mockery.core :refer [with-mocks]]) + (:import + java.io.ByteArrayInputStream)) + +(defn- mk-system + "Minimal system map for media.remote/process tests." + [] + {::setup/shared-keys {:media-processor "test-shared-key"}}) + +(defn- json-stream + "Create an InputStream from a Clojure data structure (JSON-encoded)." + [data] + (ByteArrayInputStream. + (json/encode data))) + +(def config-mock + "Standard config mock for media-processor service." + {:media-processing-service-uri "http://localhost:6065" + :media-processing-service-timeout 5000}) + +(defn- write-font-tmp + "Write font bytes to a tempfile and return the Path. Caller is responsible for cleanup." + [bytes suffix] + (let [tmp (fs/create-tempfile :prefix "penpot-test-font-" :suffix suffix)] + (io/write* tmp bytes) + tmp)) + +;; --------------------------------------------------------------------------- +;; :info +;; --------------------------------------------------------------------------- + +(t/deftest info-happy-path + (t/testing "info returns dimensions and merges into input" + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (json-stream {:width 800 :height 600 :mtype "image/jpeg" :size 12345 :orientation 1})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + result (media.remote/process (mk-system) + {:cmd :info + :input {:path path :mtype "image/jpeg"}})] + (t/is (= 800 (:width result))) + (t/is (= 600 (:height result))) + (t/is (= (fs/size path) (:size result))) + (t/is (some? (:ts result))) + (t/is (= path (:path result))) + (t/is (= "image/jpeg" (:mtype result))) + (t/is (= 1 (:call-count @mock)))))))) + +(t/deftest info-verifies-request-params + (t/testing "info sends correct endpoint, method, and x-shared-key header" + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (json-stream {:width 100 :height 100 :mtype "image/jpeg" :size 1 :orientation 1})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] + (media.remote/process (mk-system) + {:cmd :info :input {:path path :mtype "image/jpeg"}}) + (let [[system req-map] (:call-args @mock)] + ;; System passed through + (t/is (some? (::setup/shared-keys system))) + ;; Request structure + (t/is (= :post (:method req-map))) + (t/is (str/includes? (str (:uri req-map)) "api/image/info")) + (t/is (= "test-shared-key" (get-in req-map [:headers "x-shared-key"]))) + (t/is (str/starts-with? + (get-in req-map [:headers "Content-Type"]) + "multipart/form-data")))))))) + +(t/deftest info-no-content-length-header + (t/testing "info does not send Content-Length header (JDK uses chunked encoding)" + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (json-stream {:width 100 :height 100 :mtype "image/jpeg" :size 1 :orientation 1})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] + (media.remote/process (mk-system) + {:cmd :info :input {:path path :mtype "image/jpeg"}}) + (let [[_ req-map] (:call-args @mock)] + (t/is (nil? (get-in req-map [:headers "Content-Length"]))))))))) + +(t/deftest info-service-uri-not-configured + (t/testing "info throws when service URI is not configured" + (with-redefs [cf/get (constantly nil)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + err (ex/try! (media.remote/process (mk-system) + {:cmd :info :input {:path path :mtype "image/jpeg"}}))] + (t/is (ex/error? err)) + (t/is (= :internal (:type (ex-data err)))) + (t/is (= :media-processor-not-configured (:code (ex-data err)))))))) + +(t/deftest info-service-unavailable + (t/testing "info throws when service-request raises unavailable" + (with-mocks [mock {:target 'app.media.remote/service-request + :throw (ex-info "Cannot connect to media-processor service" + {:type :internal + :code :media-processor-unavailable})}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + err (ex/try! (media.remote/process (mk-system) + {:cmd :info :input {:path path :mtype "image/jpeg"}}))] + (t/is (ex/error? err)) + (t/is (= :internal (:type (ex-data err)))) + (t/is (= :media-processor-unavailable (:code (ex-data err))))))))) + +(t/deftest info-service-timeout + (t/testing "info throws when service-request raises timeout" + (with-mocks [mock {:target 'app.media.remote/service-request + :throw (ex-info "media-processor service request timed out" + {:type :internal + :code :media-processor-timeout})}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + err (ex/try! (media.remote/process (mk-system) + {:cmd :info :input {:path path :mtype "image/jpeg"}}))] + (t/is (ex/error? err)) + (t/is (= :internal (:type (ex-data err)))) + (t/is (= :media-processor-timeout (:code (ex-data err))))))))) + +(t/deftest info-mtype-mismatch + (t/testing "info raises :media-type-mismatch when detected mtype differs from declared" + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (json-stream {:width 100 :height 100 :size 100 + :mtype "image/png"})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + err (ex/try! (media.remote/process (mk-system) + {:cmd :info + :input {:path path :mtype "image/jpeg"}}))] + (t/is (ex/error? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :media-type-mismatch (:code (ex-data err))))))))) + +;; --------------------------------------------------------------------------- +;; :generic-thumbnail +;; --------------------------------------------------------------------------- + +(t/deftest generic-thumbnail-happy-path + (t/testing "generic-thumbnail returns tempfile with correct format" + (let [thumb-bytes (.getBytes "fake-jpeg-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. thumb-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + result (media.remote/process (mk-system) + {:cmd :generic-thumbnail + :input {:path path :mtype "image/jpeg"} + :format :jpeg + :quality 80 + :width 200 + :height 200})] + (t/is (= :jpeg (:format result))) + (t/is (= "image/jpeg" (:mtype result))) + (t/is (pos? (:size result))) + (t/is (fs/exists? (:data result))))))))) + +(t/deftest generic-thumbnail-verifies-query-params + (t/testing "generic-thumbnail sends correct query params with mode=fit" + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. (.getBytes "data" "UTF-8"))}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] + (media.remote/process (mk-system) + {:cmd :generic-thumbnail + :input {:path path :mtype "image/jpeg"} + :format :jpeg + :quality 85 + :width 300 + :height 400}) + (let [[_ req-map] (:call-args @mock)] + (t/is (str/includes? (str (:uri req-map)) "width=300")) + (t/is (str/includes? (str (:uri req-map)) "height=400")) + (t/is (str/includes? (str (:uri req-map)) "quality=85")) + (t/is (str/includes? (str (:uri req-map)) "format=jpeg")) + (t/is (str/includes? (str (:uri req-map)) "mode=fit")))))))) + +(t/deftest generic-thumbnail-service-unavailable + (t/testing "generic-thumbnail throws on service error" + (with-mocks [mock {:target 'app.media.remote/service-request + :throw (ex-info "Cannot connect to media-processor service" + {:type :internal + :code :media-processor-unavailable})}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + err (ex/try! (media.remote/process (mk-system) + {:cmd :generic-thumbnail + :input {:path path :mtype "image/jpeg"} + :format :jpeg + :quality 85 + :width 200 + :height 200}))] + (t/is (ex/error? err)) + (t/is (= :media-processor-unavailable (:code (ex-data err))))))))) + +;; --------------------------------------------------------------------------- +;; :profile-thumbnail +;; --------------------------------------------------------------------------- + +(t/deftest profile-thumbnail-happy-path + (t/testing "profile-thumbnail returns tempfile and uses mode=crop" + (let [thumb-bytes (.getBytes "fake-png-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. thumb-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + result (media.remote/process (mk-system) + {:cmd :profile-thumbnail + :input {:path path :mtype "image/jpeg"} + :format :jpeg + :quality 85 + :width 128 + :height 128})] + (t/is (some? (:data result))) + (t/is (fs/exists? (:data result))) + ;; Verify mode=crop in URI + (let [[_ req-map] (:call-args @mock)] + (t/is (str/includes? (str (:uri req-map)) "mode=crop"))))))))) + +(t/deftest profile-thumbnail-service-unavailable + (t/testing "profile-thumbnail throws on service error" + (with-mocks [mock {:target 'app.media.remote/service-request + :throw (ex-info "Cannot connect to media-processor service" + {:type :internal + :code :media-processor-unavailable})}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + err (ex/try! (media.remote/process (mk-system) + {:cmd :profile-thumbnail + :input {:path path :mtype "image/jpeg"} + :format :jpeg + :quality 85 + :width 128 + :height 128}))] + (t/is (ex/error? err)) + (t/is (= :media-processor-unavailable (:code (ex-data err))))))))) + +;; --------------------------------------------------------------------------- +;; :generate-fonts +;; --------------------------------------------------------------------------- + +(t/deftest generate-fonts-ttf-happy-path + (t/testing "generate-fonts with TTF path makes per-variant calls" + (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) + ttfpath (write-font-tmp ttfbytes ".ttf") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [result (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/ttf" ttfpath}})] + ;; Original path preserved + (t/is (= ttfpath (get result "font/ttf"))) + ;; Variants written to tempfiles + (t/is (fs/exists? (get result "font/otf"))) + (t/is (fs/exists? (get result "font/woff"))) + ;; Two calls: one for otf, one for woff + (t/is (= 2 (:call-count @mock)))) + (finally + (fs/delete ttfpath)))))))) + +(t/deftest generate-fonts-ttf-as-path + (t/testing "generate-fonts with TTF as tempfile Path works" + (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) + tmp-path (write-font-tmp ttfbytes ".ttf") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [result (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/ttf" tmp-path}})] + ;; Path preserved + (t/is (= tmp-path (get result "font/ttf"))) + ;; Variant written + (t/is (fs/exists? (get result "font/otf")))) + (finally + (fs/delete tmp-path)))))))) + +(t/deftest generate-fonts-otf-happy-path + (t/testing "generate-fonts with OTF path" + (let [otfbytes (io/read* (io/resource "backend_tests/test_files/font-1.otf")) + otfpath (write-font-tmp otfbytes ".otf") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [result (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/otf" otfpath}})] + (t/is (= otfpath (get result "font/otf"))) + (t/is (fs/exists? (get result "font/ttf"))) + (t/is (fs/exists? (get result "font/woff"))) + ;; Two calls: one for ttf, one for woff + (t/is (= 2 (:call-count @mock)))) + (finally + (fs/delete otfpath)))))))) + +(t/deftest generate-fonts-woff-happy-path + (t/testing "generate-fonts with WOFF path" + (let [woffbytes (io/read* (io/resource "backend_tests/test_files/font-1.woff")) + woffpath (write-font-tmp woffbytes ".woff") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [result (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/woff" woffpath}})] + (t/is (= woffpath (get result "font/woff"))) + (t/is (fs/exists? (get result "font/ttf"))) + (t/is (fs/exists? (get result "font/otf"))) + ;; Two calls: one for ttf, one for otf + (t/is (= 2 (:call-count @mock)))) + (finally + (fs/delete woffpath))))))) + + (t/deftest generate-fonts-woff2-happy-path + (t/testing "generate-fonts with WOFF2 path" + (let [woff2bytes (io/read* (io/resource "backend_tests/test_files/font-1.woff2")) + woff2path (write-font-tmp woff2bytes ".woff2") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [result (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/woff2" woff2path}})] + (t/is (= woff2path (get result "font/woff2"))) + (t/is (fs/exists? (get result "font/ttf"))) + (t/is (fs/exists? (get result "font/otf"))) + (t/is (fs/exists? (get result "font/woff"))) + ;; Three calls: one for ttf, one for otf, one for woff + (t/is (= 3 (:call-count @mock)))) + (finally + (fs/delete woff2path))))))))) + +(t/deftest generate-fonts-verifies-query-params + (t/testing "generate-fonts sends target-type query param with 180s timeout" + (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) + ttfpath (write-font-tmp ttfbytes ".ttf") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (media.remote/process (mk-system) + {:cmd :generate-fonts :input {"font/ttf" ttfpath}}) + (let [[_ req-map] (:call-args @mock)] + (t/is (str/includes? (str (:uri req-map)) "target-type=")) + (t/is (= 180000 (:timeout req-map)))) + (finally + (fs/delete ttfpath)))))))) + +(t/deftest generate-fonts-woff-verifies-target-types + (t/testing "generate-fonts with WOFF sends target-type query param" + (let [woffbytes (io/read* (io/resource "backend_tests/test_files/font-1.woff")) + woffpath (write-font-tmp woffbytes ".woff") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (media.remote/process (mk-system) + {:cmd :generate-fonts :input {"font/woff" woffpath}}) + (let [[_ req-map] (:call-args @mock)] + (t/is (str/includes? (str (:uri req-map)) "target-type="))) + (finally + (fs/delete woffpath)))))))) + +(t/deftest generate-fonts-no-recognized-variant + (t/testing "generate-fonts throws when no recognized font variant" + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [err (ex/try! (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/unknown" (.getBytes "data" "UTF-8")}}))] + (t/is (ex/error? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :invalid-font (:code (ex-data err)))))))) + +(t/deftest generate-fonts-connection-error + (t/testing "generate-fonts throws on service error" + (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) + ttfpath (write-font-tmp ttfbytes ".ttf")] + (with-mocks [mock {:target 'app.media.remote/service-request + :throw (ex-info "Cannot connect to media-processor service" + {:type :internal + :code :media-processor-unavailable})}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [err (ex/try! (media.remote/process (mk-system) + {:cmd :generate-fonts :input {"font/ttf" ttfpath}}))] + (t/is (ex/error? err)) + (t/is (= :media-processor-unavailable (:code (ex-data err))))) + (finally + (fs/delete ttfpath)))))))) + +(t/deftest generate-fonts-timeout-error + (t/testing "generate-fonts throws on service timeout" + (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) + ttfpath (write-font-tmp ttfbytes ".ttf")] + (with-mocks [mock {:target 'app.media.remote/service-request + :throw (ex-info "media-processor service request timed out" + {:type :internal + :code :media-processor-timeout})}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [err (ex/try! (media.remote/process (mk-system) + {:cmd :generate-fonts :input {"font/ttf" ttfpath}}))] + (t/is (ex/error? err)) + (t/is (= :media-processor-timeout (:code (ex-data err))))) + (finally + (fs/delete ttfpath)))))))) + +;; --------------------------------------------------------------------------- +;; Status code handling (service-request) +;; --------------------------------------------------------------------------- + +(t/deftest service-request-raises-on-400 + (t/testing "service-request raises :validation on status 400" + (with-mocks [mock {:target 'app.http.client/req + :return {:status 400 + :body (json-stream {:type "validation" + :code "invalid-image" + :hint "bad input"})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [err (ex/try! (media.remote/service-request + (mk-system) + {:method :post + :uri "http://localhost:6065/api/image/info" + :body nil + :headers {}}))] + (t/is (ex/error? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :invalid-image (:code (ex-data err))))))))) + +(t/deftest service-request-raises-on-500 + (t/testing "service-request raises :internal on status 500" + (with-mocks [mock {:target 'app.http.client/req + :return {:status 500 + :body (json-stream {:type "internal" + :code "processing-error" + :hint "Internal server error"})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [err (ex/try! (media.remote/service-request + (mk-system) + {:method :post + :uri "http://localhost:6065/api/image/info" + :body nil + :headers {}}))] + (t/is (ex/error? err)) + (t/is (= :internal (:type (ex-data err)))) + (t/is (= :processing-error (:code (ex-data err))))))))) + +(t/deftest service-request-passes-on-200 + (t/testing "service-request returns response on status 200" + (with-mocks [mock {:target 'app.http.client/req + :return {:status 200 + :body (json-stream {:width 100 :height 100})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [resp (media.remote/service-request + (mk-system) + {:method :post + :uri "http://localhost:6065/api/image/info" + :body nil + :headers {}})] + (t/is (= 200 (:status resp)))))))) + +;; --------------------------------------------------------------------------- +;; Shared key +;; --------------------------------------------------------------------------- + +(t/deftest shared-key-sent-correctly + (t/testing "x-shared-key header matches the system's shared key" + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (json-stream {:width 1 :height 1 :mtype "image/jpeg" :size 1 :orientation 1})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [system {::setup/shared-keys {:media-processor "my-secret-key-123"}}] + (media.remote/process system + {:cmd :info + :input {:path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg"}}) + (let [[system-arg _] (:call-args @mock)] + ;; System passed through correctly + (t/is (= "my-secret-key-123" + (-> system-arg ::setup/shared-keys :media-processor))))))))) + +;; --------------------------------------------------------------------------- +;; Stream closure +;; --------------------------------------------------------------------------- + +(defn- tracking-stream + "Create an InputStream that tracks whether it was closed. + Returns a map with :stream (the InputStream) and :closed (an atom)." + [^bytes data] + (let [closed (atom false) + delegate (ByteArrayInputStream. data) + stream (proxy [java.io.InputStream] [] + (read + ([] (.read delegate)) + ([^bytes b] (.read delegate b)) + ([^bytes b off len] (.read delegate b off len))) + (close [] + (reset! closed true) + (.close delegate)))] + {:stream stream :closed closed})) + +(t/deftest info-closes-response-stream + (t/testing "info closes the response stream after parsing JSON" + (let [json-str "{\"width\":100,\"height\":100,\"mtype\":\"image/jpeg\",\"size\":1,\"orientation\":1}" + json-data (.getBytes json-str "UTF-8") + {:keys [stream closed]} (tracking-stream json-data)] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body stream}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] + (media.remote/process (mk-system) + {:cmd :info + :input {:path path :mtype "image/jpeg"}}) + ;; Stream should be closed after processing + (t/is @closed))))))) + +(t/deftest font-convert-closes-response-stream + (t/testing "font-convert closes the response stream after writing" + (let [{:keys [stream closed]} (tracking-stream (.getBytes "fake-font-data" "UTF-8"))] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body stream}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) + ttfpath (write-font-tmp ttfbytes ".ttf")] + (try + (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/ttf" ttfpath}}) + ;; Stream should be closed after processing + (t/is @closed) + (finally + (fs/delete ttfpath))))))))) + +(t/deftest thumbnail-closes-response-stream + (t/testing "thumbnail closes the response stream after writing" + (let [{:keys [stream closed]} (tracking-stream (.getBytes "fake-thumbnail-data" "UTF-8"))] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body stream}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] + (media.remote/process (mk-system) + {:cmd :generic-thumbnail + :input {:path path :mtype "image/jpeg"} + :format :jpeg + :quality 85 + :width 200 + :height 200}) + ;; Stream should be closed after processing + (t/is @closed))))))) diff --git a/backend/test/backend_tests/media_sanitize_test.clj b/backend/test/backend_tests/media_sanitize_test.clj index 79c98012d2..0ea7296c1f 100644 --- a/backend/test/backend_tests/media_sanitize_test.clj +++ b/backend/test/backend_tests/media_sanitize_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.media-sanitize-test (:require diff --git a/backend/test/backend_tests/media_test.clj b/backend/test/backend_tests/media_test.clj index f4d6d78e81..2bb80bf30d 100644 --- a/backend/test/backend_tests/media_test.clj +++ b/backend/test/backend_tests/media_test.clj @@ -2,12 +2,13 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.media-test (:require [app.common.exceptions :as ex] [app.media :as media] + [app.media.svg :as svg] [backend-tests.helpers :as th] [clojure.test :as t] [datoteka.fs :as fs])) @@ -55,6 +56,87 @@ (t/is (pos? (:width info))) (t/is (pos? (:height info)))))) +(t/deftest sanitize-svg-script-tag + (t/testing "sanitize-svg removes script tags" + (let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><script>alert('xss')</script><rect width=\"50\" height=\"50\"/></svg>" + result (svg/sanitize-svg svg)] + (t/is (not (clojure.string/includes? result "<script>"))) + (t/is (not (clojure.string/includes? result "alert"))) + (t/is (clojure.string/includes? result "<rect"))))) + +(t/deftest sanitize-svg-event-handlers + (t/testing "sanitize-svg removes event handler attributes" + (let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\" onload=\"alert('xss')\"><rect width=\"50\" height=\"50\" onmouseover=\"alert('xss')\"/></svg>" + result (svg/sanitize-svg svg)] + (t/is (not (clojure.string/includes? result "onload"))) + (t/is (not (clojure.string/includes? result "onmouseover"))) + (t/is (not (clojure.string/includes? result "alert"))) + (t/is (clojure.string/includes? result "<rect"))))) + +(t/deftest sanitize-svg-javascript-href + (t/testing "sanitize-svg removes javascript: URLs from href attributes" + (let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"100\" height=\"100\"><a xlink:href=\"javascript:alert('xss')\"><rect width=\"50\" height=\"50\"/></a></svg>" + result (svg/sanitize-svg svg)] + (t/is (not (clojure.string/includes? result "javascript:"))) + (t/is (not (clojure.string/includes? result "alert"))) + (t/is (clojure.string/includes? result "<a"))))) + +(t/deftest sanitize-svg-foreign-object + (t/testing "sanitize-svg removes foreignObject elements" + (let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><foreignObject width=\"100\" height=\"100\"><body xmlns=\"http://www.w3.org/1999/xhtml\"><script>alert('xss')</script></body></foreignObject><rect width=\"50\" height=\"50\"/></svg>" + result (svg/sanitize-svg svg)] + (t/is (not (clojure.string/includes? result "foreignObject"))) + (t/is (not (clojure.string/includes? result "<script>"))) + (t/is (clojure.string/includes? result "<rect"))))) + +(t/deftest sanitize-svg-clean-content + (t/testing "sanitize-svg preserves clean SVG content" + (let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><rect width=\"50\" height=\"50\" fill=\"red\"/><circle cx=\"75\" cy=\"75\" r=\"20\" fill=\"blue\"/></svg>" + result (svg/sanitize-svg svg)] + (t/is (clojure.string/includes? result "<rect")) + (t/is (clojure.string/includes? result "<circle")) + (t/is (or (clojure.string/includes? result "fill=\"red\"") + (clojure.string/includes? result "fill='red'"))) + (t/is (or (clojure.string/includes? result "fill=\"blue\"") + (clojure.string/includes? result "fill='blue'")))))) + +(t/deftest sanitize-svg-invalid-svg-rejected + (t/testing "sanitize-svg rejects malformed SVG input" + (let [svg "<svg><not-closed>"] + (t/is (thrown-with-msg? Exception #"SVG parsing failed during sanitization" + (svg/sanitize-svg svg)))))) + +(t/deftest sanitize-svg-preserves-xlink + (t/testing "sanitize-svg preserves legitimate xlink:href attributes" + (let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"100\" height=\"100\"><use xlink:href=\"#icon\"/></svg>" + result (svg/sanitize-svg svg)] + (t/is (clojure.string/includes? result "xlink:href")) + (t/is (clojure.string/includes? result "#icon"))))) + +(t/deftest sanitize-svg-javascript-href-whitespace + (t/testing "sanitize-svg catches javascript: URLs with leading whitespace" + (let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><a href=\" javascript:alert('xss')\"><rect width=\"50\" height=\"50\"/></a></svg>" + result (svg/sanitize-svg svg)] + (t/is (not (clojure.string/includes? result "javascript:"))) + (t/is (not (clojure.string/includes? result "alert"))) + (t/is (clojure.string/includes? result "<a"))))) + +(t/deftest sanitize-svg-nested-script + (t/testing "sanitize-svg removes script tags from nested elements" + (let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><g><script>alert('xss')</script></g></svg>" + result (svg/sanitize-svg svg)] + (t/is (not (clojure.string/includes? result "<script"))) + (t/is (not (clojure.string/includes? result "alert"))) + (t/is (clojure.string/includes? result "<g"))))) + +(t/deftest sanitize-svg-smil-bypass + (t/testing "sanitize-svg removes SMIL animation elements that can set on* attrs" + (let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><rect width=\"100\" height=\"100\" id=\"r\"/><set attributeName=\"onmouseover\" to=\"alert('xss')\" xlink:href=\"#r\" begin=\"0s\"/></svg>" + result (svg/sanitize-svg svg)] + (t/is (not (clojure.string/includes? result "<set"))) + (t/is (not (clojure.string/includes? result "onmouseover"))) + (t/is (clojure.string/includes? result "<rect"))))) + (t/deftest info-invalid-image (t/testing "info on invalid image raises error" (let [path (fs/create-tempfile :prefix "penpot-test-" :suffix ".jpg")] diff --git a/backend/test/backend_tests/passwords_test.clj b/backend/test/backend_tests/passwords_test.clj new file mode 100644 index 0000000000..75a880a5de --- /dev/null +++ b/backend/test/backend_tests/passwords_test.clj @@ -0,0 +1,51 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.passwords-test + (:require + [app.auth.passwords :as passwords] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(defn- run-validation + [password] + (try + (passwords/validate-password password) + nil + (catch Throwable e + e))) + +(t/deftest validate-password-accepts-strong-password + (t/is (nil? (run-validation "Str0ng!Pass")))) + +(t/deftest validate-password-rejects-too-short-password + (let [error (run-validation "Ab1!x")] + (t/is (th/ex-of-code? error :weak-password)) + (t/is (= ["errors.weak-password.too-short"] (:details (ex-data error)))))) + +(t/deftest validate-password-rejects-missing-lowercase + (let [error (run-validation "ABCDEFG1!")] + (t/is (th/ex-of-code? error :weak-password)) + (t/is (= ["errors.weak-password.insufficient-lowercase"] + (:details (ex-data error)))))) + +(t/deftest validate-password-rejects-missing-uppercase + (let [error (run-validation "abcdefg1!")] + (t/is (th/ex-of-code? error :weak-password)) + (t/is (= ["errors.weak-password.insufficient-uppercase"] + (:details (ex-data error)))))) + +(t/deftest validate-password-rejects-missing-digit + (let [error (run-validation "Abcdefgh!")] + (t/is (th/ex-of-code? error :weak-password)) + (t/is (= ["errors.weak-password.insufficient-digits"] + (:details (ex-data error)))))) + +(t/deftest validate-password-rejects-missing-special + (let [error (run-validation "Abcdefgh1")] + (t/is (th/ex-of-code? error :weak-password)) + (t/is (= ["errors.weak-password.insufficient-special"] + (:details (ex-data error)))))) \ No newline at end of file diff --git a/backend/test/backend_tests/rpc_access_tokens_test.clj b/backend/test/backend_tests/rpc_access_tokens_test.clj index bdb2f20887..5fa3a1496a 100644 --- a/backend/test/backend_tests/rpc_access_tokens_test.clj +++ b/backend/test/backend_tests/rpc_access_tokens_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-access-tokens-test (:require diff --git a/backend/test/backend_tests/rpc_audit_test.clj b/backend/test/backend_tests/rpc_audit_test.clj index f4cb76f2a3..9a6e4f198d 100644 --- a/backend/test/backend_tests/rpc_audit_test.clj +++ b/backend/test/backend_tests/rpc_audit_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-audit-test (:require diff --git a/backend/test/backend_tests/rpc_auth_test.clj b/backend/test/backend_tests/rpc_auth_test.clj new file mode 100644 index 0000000000..0a23c3cab0 --- /dev/null +++ b/backend/test/backend_tests/rpc_auth_test.clj @@ -0,0 +1,106 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.rpc-auth-test + (:require + [app.common.uuid :as uuid] + [app.http.session :as session] + [backend-tests.helpers :as th] + [clojure.test :as t] + [yetti.response :as yres])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(t/deftest logout-invalidates-current-session + (let [prof (th/create-profile* 1) + manager (::session/manager th/*system*) + sid (uuid/random) + _ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)" + sid (:id prof) "test-agent"]) + session (session/read-session manager sid)] + + ;; Arrange: session exists before logout + (t/is (some? session) "session should exist before logout") + (t/is (= sid (:id session))) + + ;; Act: simulate Ring request as produced by wrap-authz (has ::session/session) + ;; delete-fn is used as response transform via rph/with-transform in auth/logout + (let [request {::session/session session} + response {} + delete-fn (session/delete-fn th/*system*) + result (delete-fn request response)] + + ;; Assert: server-side session is deleted (CWE-613) + (t/is (nil? (session/read-session manager sid)) + "session must be deleted server-side after logout (GHSA-mj9f-5cwq-7p3q)") + + ;; Assert: cookie is cleared + (t/is (= "" (get-in result [::yres/cookies "auth-token" :value])) + "auth-token cookie should be cleared") + (t/is (= 0 (get-in result [::yres/cookies "auth-token" :max-age])) + "auth-token cookie max-age should be 0")))) + +(t/deftest logout-clears-cookie-even-when-session-missing + (let [manager (::session/manager th/*system*) + sid (uuid/random) + ;; No session inserted, read should be nil + _ (t/is (nil? (session/read-session manager sid))) + request {} + response {} + delete-fn (session/delete-fn th/*system*) + result (delete-fn request response)] + + ;; Should still clear cookie (idempotent) + (t/is (= "" (get-in result [::yres/cookies "auth-token" :value]))) + (t/is (= 0 (get-in result [::yres/cookies "auth-token" :max-age]))))) + +(t/deftest logout-does-not-invalidate-other-sessions + (let [prof (th/create-profile* 1) + manager (::session/manager th/*system*) + sid1 (uuid/random) + sid2 (uuid/random) + _ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)" + sid1 (:id prof) "agent-1"]) + _ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)" + sid2 (:id prof) "agent-2"]) + s1 (session/read-session manager sid1) + s2 (session/read-session manager sid2)] + + (t/is (some? s1)) + (t/is (some? s2)) + + ;; Logout only sid1 + (let [request {::session/session s1} + response {} + delete-fn (session/delete-fn th/*system*)] + (delete-fn request response)) + + ;; sid1 deleted, sid2 intact + (t/is (nil? (session/read-session manager sid1)) "current session should be deleted") + (t/is (some? (session/read-session manager sid2)) "other sessions should remain"))) + +(t/deftest replay-after-logout-cannot-authenticate + (let [prof (th/create-profile* 1) + manager (::session/manager th/*system*) + sid (uuid/random) + _ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)" + sid (:id prof) "test-agent"]) + session (session/read-session manager sid)] + + (t/is (some? session) "session exists before logout") + + ;; Simulate logout + (let [request {::session/session session} + response {} + delete-fn (session/delete-fn th/*system*)] + (delete-fn request response)) + + ;; Replay: attempt to read session with same sid should fail (no profile attached) + (t/is (nil? (session/read-session manager sid)) + "replayed token must not resolve to a valid session after logout"))) + + diff --git a/backend/test/backend_tests/rpc_binfile_test.clj b/backend/test/backend_tests/rpc_binfile_test.clj new file mode 100644 index 0000000000..879696fb0e --- /dev/null +++ b/backend/test/backend_tests/rpc_binfile_test.clj @@ -0,0 +1,65 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.rpc-binfile-test + (:require + [app.common.schema :as sm] + [app.common.uuid :as uuid] + [app.rpc :as-alias rpc] + [app.rpc.commands.binfile :as binfile] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(t/deftest import-binfile-schema-omits-file-id + ;; N1-06: file-id parameter must be removed from schema for security + (let [schema @#'binfile/schema:import-binfile + validator (sm/lazy-validator schema) + + valid-params {:name "test" + :project-id (uuid/random) + :version 3 + :upload-id (uuid/random)}] + + (t/is (true? (validator valid-params)) + "params without file-id should be valid") + + (t/is (not (contains? (sm/keys (second schema)) :file-id)) + "file-id should not be a declared parameter"))) + +(t/deftest import-binfile-schema-rejects-unsupported-version + ;; T1-N2-03: version parameter should be restricted to supported values (1 or 3) + (let [schema @#'binfile/schema:import-binfile + validator (sm/lazy-validator schema) + base-params {:name "test" + :project-id (uuid/random) + :upload-id (uuid/random)}] + + ;; Version 1 should be accepted + (t/is (true? (validator (assoc base-params :version 1))) + "version 1 should be valid") + + ;; Version 3 should be accepted + (t/is (true? (validator (assoc base-params :version 3))) + "version 3 should be valid") + + ;; Version 2 should be rejected + (t/is (false? (validator (assoc base-params :version 2))) + "version 2 should be rejected") + + ;; Version 0 should be rejected + (t/is (false? (validator (assoc base-params :version 0))) + "version 0 should be rejected") + + ;; Negative version should be rejected + (t/is (false? (validator (assoc base-params :version -1))) + "negative version should be rejected") + + ;; Version 4 should be rejected + (t/is (false? (validator (assoc base-params :version 4))) + "version 4 should be rejected"))) diff --git a/backend/test/backend_tests/rpc_commands_error_reports_test.clj b/backend/test/backend_tests/rpc_commands_error_reports_test.clj index c317fe0cbd..868023c79c 100644 --- a/backend/test/backend_tests/rpc_commands_error_reports_test.clj +++ b/backend/test/backend_tests/rpc_commands_error_reports_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en Espana SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-commands-error-reports-test (:require diff --git a/backend/test/backend_tests/rpc_comment_test.clj b/backend/test/backend_tests/rpc_comment_test.clj index 8724cbfdfa..ea64723545 100644 --- a/backend/test/backend_tests/rpc_comment_test.clj +++ b/backend/test/backend_tests/rpc_comment_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-comment-test (:require @@ -285,3 +285,196 @@ (let [threads (th/db-query :comment-thread {:file-id (:id file-1)})] (t/is (= 0 (count threads))))))))) + +(t/deftest share-link-who-comment-team-cannot-comment + (let [owner (th/create-profile* 1 {:is-active true}) + outsider (th/create-profile* 2 {:is-active true}) + + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:team-id (:id team) + :profile-id (:id owner)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + page-id (get-in file [:data :pages 0]) + + share (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{page-id} + :who-comment "team" + :who-inspect "all"}) + share-id (get-in share [:result :id])] + + (t/testing "outsider with who-comment=team share-link cannot get-comment-threads" + (let [out (th/command! {::th/type :get-comment-threads + ::rpc/profile-id (:id outsider) + :file-id (:id file) + :share-id share-id})] + (t/is (not (th/success? out))) + (t/is (= :not-found (th/ex-type (:error out)))))) + + (t/testing "outsider with who-comment=team share-link cannot create-comment-thread" + (let [out (th/command! {::th/type :create-comment-thread + ::rpc/profile-id (:id outsider) + :file-id (:id file) + :page-id page-id + :position (gpt/point 0) + :content "outsider comment" + :frame-id uuid/zero + :share-id share-id})] + (t/is (not (th/success? out))) + (t/is (= :not-found (th/ex-type (:error out)))))))) + +(t/deftest share-link-who-comment-all-can-comment + (let [owner (th/create-profile* 1 {:is-active true}) + outsider (th/create-profile* 2 {:is-active true}) + + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:team-id (:id team) + :profile-id (:id owner)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + page-id (get-in file [:data :pages 0]) + + share (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{page-id} + :who-comment "all" + :who-inspect "all"}) + share-id (get-in share [:result :id])] + + (t/testing "outsider with who-comment=all share-link can get-comment-threads" + (let [out (th/command! {::th/type :get-comment-threads + ::rpc/profile-id (:id outsider) + :file-id (:id file) + :share-id share-id})] + (t/is (th/success? out)))) + + (t/testing "outsider with who-comment=all share-link can create-comment-thread" + (let [out (th/command! {::th/type :create-comment-thread + ::rpc/profile-id (:id outsider) + :file-id (:id file) + :page-id page-id + :position (gpt/point 0) + :content "outsider comment" + :frame-id uuid/zero + :share-id share-id})] + (t/is (th/success? out)))))) + +(t/deftest share-link-page-scope-enforced + (let [owner (th/create-profile* 1 {:is-active true}) + outsider (th/create-profile* 2 {:is-active true}) + + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:team-id (:id team) + :profile-id (:id owner)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + + page-a (get-in file [:data :pages 0]) + page-b (uuid/random) + + _ (th/command! {::th/type :update-file + ::rpc/profile-id (:id owner) + :id (:id file) + :session-id (uuid/random) + :revn 0 + :vern 0 + :changes [{:type :add-page + :id page-b + :page {:id page-b + :name "Page B" + :options {} + :objects {}}}]}) + + thread-a (th/command! {::th/type :create-comment-thread + ::rpc/profile-id (:id owner) + :file-id (:id file) + :page-id page-a + :position (gpt/point 0) + :content "comment on page A" + :frame-id uuid/zero}) + thread-b (th/command! {::th/type :create-comment-thread + ::rpc/profile-id (:id owner) + :file-id (:id file) + :page-id page-b + :position (gpt/point 0) + :content "comment on page B" + :frame-id uuid/zero}) + + thread-a-id (get-in thread-a [:result :id]) + thread-b-id (get-in thread-b [:result :id]) + + share (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{page-a} + :who-comment "all" + :who-inspect "all"}) + share-id (get-in share [:result :id])] + + (t/testing "share-link holder can get-comment-threads for shared page only" + (let [out (th/command! {::th/type :get-comment-threads + ::rpc/profile-id (:id outsider) + :file-id (:id file) + :share-id share-id}) + result (:result out)] + (t/is (th/success? out)) + (t/is (= 1 (count result))) + (t/is (= page-a (:page-id (first result)))))) + + (t/testing "share-link holder cannot get-comment-thread for unshared page" + (let [out (th/command! {::th/type :get-comment-thread + ::rpc/profile-id (:id outsider) + :file-id (:id file) + :id thread-b-id + :share-id share-id})] + (t/is (not (th/success? out))) + (t/is (= :not-found (th/ex-type (:error out)))))) + + (t/testing "share-link holder can get-comment-thread for shared page" + (let [out (th/command! {::th/type :get-comment-thread + ::rpc/profile-id (:id outsider) + :file-id (:id file) + :id thread-a-id + :share-id share-id})] + (t/is (th/success? out)))) + + (t/testing "share-link holder cannot get-comments for thread on unshared page" + (let [out (th/command! {::th/type :get-comments + ::rpc/profile-id (:id outsider) + :thread-id thread-b-id + :share-id share-id})] + (t/is (not (th/success? out))) + (t/is (= :not-found (th/ex-type (:error out)))))))) + +(t/deftest membership-can-still-comment + (let [owner (th/create-profile* 1 {:is-active true}) + member (th/create-profile* 2 {:is-active true}) + + team (th/create-team* 1 {:profile-id (:id owner)}) + _ (th/create-team-role* {:team-id (:id team) + :profile-id (:id member) + :role :editor}) + project (th/create-project* 1 {:team-id (:id team) + :profile-id (:id owner)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + page-id (get-in file [:data :pages 0])] + + (t/testing "team member can get-comment-threads without share-id" + (let [out (th/command! {::th/type :get-comment-threads + ::rpc/profile-id (:id member) + :file-id (:id file)})] + (t/is (th/success? out)))) + + (t/testing "team member can create-comment-thread without share-id" + (let [out (th/command! {::th/type :create-comment-thread + ::rpc/profile-id (:id member) + :file-id (:id file) + :page-id page-id + :position (gpt/point 0) + :content "member comment" + :frame-id uuid/zero})] + (t/is (th/success? out)))))) diff --git a/backend/test/backend_tests/rpc_cond_middleware_test.clj b/backend/test/backend_tests/rpc_cond_middleware_test.clj index e6725699e6..21a503aa58 100644 --- a/backend/test/backend_tests/rpc_cond_middleware_test.clj +++ b/backend/test/backend_tests/rpc_cond_middleware_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-cond-middleware-test (:require diff --git a/backend/test/backend_tests/rpc_demo_test.clj b/backend/test/backend_tests/rpc_demo_test.clj new file mode 100644 index 0000000000..3bda13fc61 --- /dev/null +++ b/backend/test/backend_tests/rpc_demo_test.clj @@ -0,0 +1,76 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.rpc-demo-test + (:require + [app.auth :as auth] + [app.config :as cf] + [app.rpc.commands.profile :as profile] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +;; Capture the real verifier before the shared test fixture replaces it. +(def verify-password* auth/verify-password) + +(t/deftest weak-password-hash-verifies + (let [password "DemoPassword123!" + hashed (auth/derive-password-weak password)] + (t/is (:valid (verify-password* password hashed))))) + +(t/deftest create-demo-profile-uses-unique-uuid-email + (with-redefs [cf/flags (conj cf/flags :demo-users)] + (let [first-result (th/command! {::th/type :create-demo-profile}) + second-result (th/command! {::th/type :create-demo-profile}) + first-profile (:result first-result) + second-profile (:result second-result)] + (t/is (nil? (:error first-result))) + (t/is (nil? (:error second-result))) + (t/is (re-matches #"demo-[0-9a-fA-F-]+@demo\.example\.com" + (:email first-profile))) + (t/is (not= (:email first-profile) (:email second-profile)))))) + +(t/deftest create-demo-profile-requires-feature-flag + (with-redefs [cf/flags (disj cf/flags :demo-users)] + (let [{:keys [error]} (th/command! {::th/type :create-demo-profile})] + (t/is (th/ex-of-code? error :demo-users-not-allowed))))) + +(t/deftest create-demo-profile-keeps-onboarding-by-default + (with-redefs [cf/flags (conj cf/flags :demo-users)] + (let [{:keys [error result]} (th/command! {::th/type :create-demo-profile})] + (t/is (nil? error)) + (let [saved (th/db-get :profile {:email (:email result)}) + decoded (profile/decode-row saved)] + (t/is (nil? (get-in decoded [:props :onboarding-viewed]))))))) + +(t/deftest create-demo-profile-skips-onboarding-when-requested + (with-redefs [cf/flags (conj cf/flags :demo-users)] + (let [{:keys [error result]} (th/command! {::th/type :create-demo-profile + :skip-onboarding true})] + (t/is (nil? error)) + (let [saved (th/db-get :profile {:email (:email result)}) + decoded (profile/decode-row saved)] + (t/is (true? (get-in decoded [:props :onboarding-viewed]))) + (t/is (= (:main cf/version) + (get-in decoded [:props :release-notes-viewed]))))))) + +(t/deftest create-demo-profile-explicit-false-keeps-onboarding + (with-redefs [cf/flags (conj cf/flags :demo-users)] + (let [{:keys [error result]} (th/command! {::th/type :create-demo-profile + :skip-onboarding false})] + (t/is (nil? error)) + (let [saved (th/db-get :profile {:email (:email result)}) + decoded (profile/decode-row saved)] + (t/is (nil? (get-in decoded [:props :onboarding-viewed]))))))) + +(t/deftest create-demo-profile-rejects-non-boolean-skip-onboarding + (with-redefs [cf/flags (conj cf/flags :demo-users)] + (let [{:keys [error]} (th/command! {::th/type :create-demo-profile + :skip-onboarding "yes"})] + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :params-validation))))) diff --git a/backend/test/backend_tests/rpc_doc_test.clj b/backend/test/backend_tests/rpc_doc_test.clj index 7a79ea8a18..7398d57949 100644 --- a/backend/test/backend_tests/rpc_doc_test.clj +++ b/backend/test/backend_tests/rpc_doc_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-doc-test "Internal binfile test, no RPC involved" diff --git a/backend/test/backend_tests/rpc_feedback_test.clj b/backend/test/backend_tests/rpc_feedback_test.clj new file mode 100644 index 0000000000..b4a6ae650d --- /dev/null +++ b/backend/test/backend_tests/rpc_feedback_test.clj @@ -0,0 +1,39 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.rpc-feedback-test + (:require + [app.common.schema :as sm] + [app.rpc.commands.feedback :as feedback] + [clojure.test :as t])) + +(t/deftest send-user-feedback-schema-validation + (let [schema feedback/schema:send-user-feedback] + + (t/testing "accepts valid feedback with all fields" + (let [params {:subject "Test subject" + :content "Test content" + :type "bug" + :error-href "https://example.com/error" + :error-report "Error details here"}] + (t/is (sm/valid? schema params)))) + + (t/testing "accepts feedback without optional fields" + (let [params {:subject "Test subject" + :content "Test content"}] + (t/is (sm/valid? schema params)))) + + (t/testing "accepts error-report up to 1MiB" + (let [params {:subject "Test subject" + :content "Test content" + :error-report (apply str (repeat 1048576 "x"))}] + (t/is (sm/valid? schema params)))) + + (t/testing "rejects error-report exceeding 1MiB" + (let [params {:subject "Test subject" + :content "Test content" + :error-report (apply str (repeat 1048577 "x"))}] + (t/is (not (sm/valid? schema params))))))) diff --git a/backend/test/backend_tests/rpc_file_snapshot_test.clj b/backend/test/backend_tests/rpc_file_snapshot_test.clj index 5e8bb8ea21..aed2b5ce39 100644 --- a/backend/test/backend_tests/rpc_file_snapshot_test.clj +++ b/backend/test/backend_tests/rpc_file_snapshot_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-file-snapshot-test (:require diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 1c07f35971..a54774b2c6 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-file-test (:require @@ -141,6 +141,31 @@ (let [result (:result out)] (t/is (= 0 (count result)))))))) +(t/deftest create-file-with-duplicate-id + (let [prof (th/create-profile* 1 {:is-active true}) + proj-id (:default-project-id prof) + file-id (uuid/next)] + + (t/testing "create file with specific id" + (let [data {::th/type :create-file + ::rpc/profile-id (:id prof) + :project-id proj-id + :id file-id + :name "first-file"} + out (th/command! data)] + (t/is (nil? (:error out))))) + + (t/testing "create file with duplicate id returns normalized error" + (let [data {::th/type :create-file + ::rpc/profile-id (:id prof) + :project-id proj-id + :id file-id + :name "duplicate-file"} + out (th/command! data) + err (:error out)] + (t/is (th/ex-info? err)) + (t/is (th/ex-of-type? err :not-found)))))) + (t/deftest file-gc-with-fragments (let [profile (th/create-profile* 1) file (th/create-file* 1 {:profile-id (:id profile) @@ -708,7 +733,7 @@ (t/is (= 2 (count rows))) (t/is (= 1 (count (remove (comp some? :deleted-at) rows)))) (t/is (= (thc/fmt-object-id file-id page-id frame-id-1 "frame") - (-> rows first :object-id)))) + (->> rows (remove (comp some? :deleted-at)) first :object-id)))) ;; Now that file-gc have marked for deletion the object ;; thumbnail lets execute the objects-gc task which remove @@ -983,6 +1008,38 @@ (t/is (some? sync)) (t/is (some? (:synced-at sync))))) +(t/deftest link-file-to-library-rejects-cross-team + ;; N1-08: A file in team2 must not be linked to a library in team1, + ;; even when the user has edit permissions on both (BOLA / CWE-639). + (let [prof1 (th/create-profile* 1) + prof2 (th/create-profile* 2) + team1 (th/create-team* 1 {:profile-id (:id prof1)}) + team2 (th/create-team* 2 {:profile-id (:id prof2)}) + proj1 (th/create-project* 1 {:profile-id (:id prof1) + :team-id (:id team1)}) + proj2 (th/create-project* 2 {:profile-id (:id prof2) + :team-id (:id team2)}) + lib (th/create-file* 1 {:project-id (:id proj1) + :profile-id (:id prof1) + :is-shared true}) + file2 (th/create-file* 2 {:project-id (:id proj2) + :profile-id (:id prof2)})] + + ;; Add prof2 as editor to team1 so they have edit access to the library + (th/db-insert! :team-profile-rel {:team-id (:id team1) + :profile-id (:id prof2) + :is-owner false + :is-admin false + :can-edit true}) + + ;; prof2 tries to link file2 (team2) to lib (team1) — must fail + (let [data {::th/type :link-file-to-library + ::rpc/profile-id (:id prof2) + :file-id (:id file2) + :library-id (:id lib)} + out (th/command! data)] + (t/is (some? (:error out)))))) + (t/deftest update-file-library-sync-status-updates-sync-row (let [profile (th/create-profile* 1) file1 (th/create-file* 1 {:project-id (:default-project-id profile) @@ -2320,8 +2377,6 @@ (let [edata (-> out :error ex-data)] (t/is (= :not-found (:type edata)))))) -;; --- Security Fix Tests --- - (t/deftest link-file-to-library-circular-reference (let [profile (th/create-profile* 1) file1 (th/create-file* 1 {:profile-id (:id profile) @@ -2391,3 +2446,251 @@ (t/is (th/ex-info? (:error out))) (let [edata (-> out :error ex-data)] (t/is (= :validation (:type edata)))))) + +(t/deftest get-file-libraries-nonexistent-file + (let [prof (th/create-profile* 1 {:is-active true}) + out (th/command! {::th/type :get-file-libraries + ::rpc/profile-id (:id prof) + :file-id (uuid/random)}) + err (:error out)] + (t/is (th/ex-info? err)) + (t/is (th/ex-of-type? err :not-found)))) + +(t/deftest get-file-libraries-no-permission + (let [owner (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:default-project-id owner)}) + out (th/command! {::th/type :get-file-libraries + ::rpc/profile-id (:id other) + :file-id (:id file)}) + err (:error out)] + (t/is (th/ex-info? err)) + (t/is (th/ex-of-type? err :not-found)))) + +(t/deftest share-link-deletion-idor + (let [owner (th/create-profile* 1 {:is-active true}) + editor (th/create-profile* 2 {:is-active true}) + admin (th/create-profile* 3 {:is-active true}) + proj-id (:default-project-id owner) + team-id (:default-team-id owner) + + file (th/create-file* 1 {:profile-id (:id owner) + :project-id proj-id + :is-shared false}) + + ;; Invite editor to the team with edit permissions + _ (th/create-team-role* {:team-id team-id + :profile-id (:id editor) + :role :editor}) + + ;; Invite admin to the team with admin permissions + _ (th/create-team-role* {:team-id team-id + :profile-id (:id admin) + :role :admin}) + + ;; Owner creates a share-link + slink (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{(get-in file [:data :pages 0])} + :who-comment "team" + :who-inspect "all"}) + slink-id (get-in slink [:result :id])] + + (t/testing "owner can delete their own share-link" + (let [out (th/command! {::th/type :delete-share-link + ::rpc/profile-id (:id owner) + :id slink-id})] + (t/is (nil? (:error out))))) + + (t/testing "editor CANNOT delete owner's share-link (IDOR)" + ;; Recreate the share-link for this test + (let [slink2 (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{} + :who-comment "team" + :who-inspect "team"}) + slink2-id (get-in slink2 [:result :id]) + + ;; Editor tries to delete owner's share-link + out (th/command! {::th/type :delete-share-link + ::rpc/profile-id (:id editor) + :id slink2-id}) + err (:error out) + edata (ex-data err)] + + ;; Should be denied with authorization error + (t/is (th/ex-info? err)) + (t/is (= :authorization (:type edata))) + + ;; Verify the share-link still exists + (let [check (th/command! {::th/type :get-view-only-bundle + ::rpc/profile-id (:id owner) + :file-id (:id file)}) + share-links (:share-links (:result check))] + (t/is (some #(= slink2-id (:id %)) share-links))))))) + +(t/deftest share-link-page-scope-enforcement + (let [owner (th/create-profile* 1 {:is-active true}) + viewer (th/create-profile* 2 {:is-active true}) + proj-id (:default-project-id owner) + + file (th/create-file* 1 {:profile-id (:id owner) + :project-id proj-id + :is-shared false}) + + page-a (get-in file [:data :pages 0]) + page-b (uuid/random) + + ;; Add a second page to the file + _ (th/command! {::th/type :update-file + ::rpc/profile-id (:id owner) + :id (:id file) + :session-id (uuid/random) + :revn 0 + :vern 0 + :changes [{:type :add-page + :id page-b + :page {:id page-b + :name "Page B" + :options {} + :objects {}}}]}) + + ;; Create share-link scoped to page A only + share (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{page-a} + :who-comment "team" + :who-inspect "all"}) + share-id (get-in share [:result :id])] + + (t/testing "share-link holder can access authorized page" + (let [out (th/command! {::th/type :get-page + ::rpc/profile-id (:id viewer) + :file-id (:id file) + :page-id page-a + :share-id share-id})] + (t/is (nil? (:error out))) + (t/is (some? (:result out))))) + + (t/testing "share-link holder cannot access out-of-scope page" + (let [out (th/command! {::th/type :get-page + ::rpc/profile-id (:id viewer) + :file-id (:id file) + :page-id page-b + :share-id share-id}) + err (:error out) + edata (ex-data err)] + (t/is (th/ex-info? err)) + (t/is (= :not-found (:type edata))) + (t/is (= :object-not-found (:code edata))))) + + (t/testing "team member can access all pages" + (let [out-a (th/command! {::th/type :get-page + ::rpc/profile-id (:id owner) + :file-id (:id file) + :page-id page-a}) + out-b (th/command! {::th/type :get-page + ::rpc/profile-id (:id owner) + :file-id (:id file) + :page-id page-b})] + (t/is (nil? (:error out-a))) + (t/is (nil? (:error out-b))))))) + +(t/deftest share-link-deletion-escape-hatches + (let [owner (th/create-profile* 1 {:is-active true}) + editor (th/create-profile* 2 {:is-active true}) + admin (th/create-profile* 3 {:is-active true}) + proj-id (:default-project-id owner) + team-id (:default-team-id owner) + + file (th/create-file* 1 {:profile-id (:id owner) + :project-id proj-id + :is-shared false}) + + ;; Invite editor to the team with edit permissions + _ (th/create-team-role* {:team-id team-id + :profile-id (:id editor) + :role :editor}) + + ;; Invite admin to the team with admin permissions + _ (th/create-team-role* {:team-id team-id + :profile-id (:id admin) + :role :admin})] + + (t/testing "editor CAN delete their own share-link" + (let [slink (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id editor) + :file-id (:id file) + :pages #{} + :who-comment "team" + :who-inspect "team"}) + slink-id (get-in slink [:result :id]) + + out (th/command! {::th/type :delete-share-link + ::rpc/profile-id (:id editor) + :id slink-id})] + (t/is (nil? (:error out))))) + + (t/testing "admin CAN delete editor's share-link" + (let [slink (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id editor) + :file-id (:id file) + :pages #{} + :who-comment "team" + :who-inspect "team"}) + slink-id (get-in slink [:result :id]) + + out (th/command! {::th/type :delete-share-link + ::rpc/profile-id (:id admin) + :id slink-id})] + (t/is (nil? (:error out))))) + + (t/testing "owner CAN delete editor's share-link" + (let [slink (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id editor) + :file-id (:id file) + :pages #{} + :who-comment "team" + :who-inspect "team"}) + slink-id (get-in slink [:result :id]) + + out (th/command! {::th/type :delete-share-link + ::rpc/profile-id (:id owner) + :id slink-id})] + (t/is (nil? (:error out))))))) + +(t/deftest share-link-fragment-access-denied + (let [owner (th/create-profile* 1 {:is-active true}) + viewer (th/create-profile* 2 {:is-active true}) + proj-id (:default-project-id owner) + + file (th/create-file* 1 {:profile-id (:id owner) + :project-id proj-id + :is-shared false}) + + page-a (get-in file [:data :pages 0]) + + ;; Create share-link + share (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{page-a} + :who-comment "team" + :who-inspect "all"}) + share-id (get-in share [:result :id])] + + (t/testing "share-link holder cannot access file fragments" + (let [out (th/command! {::th/type :get-file-fragment + ::rpc/profile-id (:id viewer) + :file-id (:id file) + :fragment-id (uuid/random) + :share-id share-id}) + err (:error out) + edata (ex-data err)] + (t/is (th/ex-info? err)) + (t/is (= :not-found (:type edata))) + (t/is (= :object-not-found (:code edata))))))) diff --git a/backend/test/backend_tests/rpc_file_thumbnails_test.clj b/backend/test/backend_tests/rpc_file_thumbnails_test.clj index 4fb4ab12e1..5273180c15 100644 --- a/backend/test/backend_tests/rpc_file_thumbnails_test.clj +++ b/backend/test/backend_tests/rpc_file_thumbnails_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-file-thumbnails-test (:require diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index 234bcba89e..eb9f6cc69d 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-font-test (:require @@ -24,111 +24,80 @@ (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) -(t/deftest ttf-font-upload-1 - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - proj-id (:default-project-id prof) - font-id (uuid/custom 10 1) +;; ----------------------------------------------------------------------- +;; Helpers for chunked-upload font tests +;; ----------------------------------------------------------------------- - ttfdata (-> (io/resource "backend_tests/test_files/font-1.ttf") - (io/read*)) +(defn- split-bytes-into-chunks + "Splits `data` (byte array) into chunks of at most `chunk-size` bytes. + Returns a vector of byte arrays." + [^bytes data chunk-size] + (let [length (alength data)] + (loop [offset 0 chunks []] + (if (>= offset length) + chunks + (let [remaining (- length offset) + size (min chunk-size remaining) + buf (byte-array size)] + (System/arraycopy data offset buf 0 size) + (recur (+ offset size) (conj chunks buf))))))) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 400 - :font-style "normal" - :data {"font/ttf" ttfdata}} - out (th/command! params)] +(defn- make-chunk-mfile + "Writes `data` (byte array) to a tempfile and returns a map + compatible with the upload-chunk :content parameter." + [^bytes data mtype] + (let [tmp (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-font-chunk-")] + (io/write* tmp data) + {:filename "chunk" + :path tmp + :mtype mtype + :size (alength data)})) - (t/is (= 1 (:call-count @mock))) +(defn- create-upload-session! + "Creates an upload session for `prof` with `total-chunks`. Returns the session-id UUID." + [prof total-chunks] + (let [out (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks total-chunks})] + (let [session-id (:session-id (:result out))] + (t/is (nil? (:error out)) + (str "create-upload-session failed: " + (some-> (:error out) ex-data))) + (t/is (uuid? session-id) + (str "create-upload-session returned an invalid session-id: " session-id)) + session-id))) - ;; (th/print-result! out) - (t/is (nil? (:error out))) - (let [result (:result out)] - (t/is (uuid? (:id result))) - (t/is (uuid? (:ttf-file-id result))) - (t/is (uuid? (:otf-file-id result))) - (t/is (uuid? (:woff1-file-id result))) - (t/are [k] (= (get params k) - (get result k)) - :team-id - :font-id - :font-family - :font-weight - :font-style))))) +(defn- upload-font-chunked! + "Splits `font-bytes` into chunks of `chunk-size` bytes, creates an upload + session, uploads all chunks, and returns the session-id UUID." + [prof ^bytes font-bytes mtype chunk-size] + (let [chunks (split-bytes-into-chunks font-bytes chunk-size) + session-id (create-upload-session! prof (count chunks))] + (when (uuid? session-id) + (doseq [[idx chunk-data] (map-indexed vector chunks)] + (let [mfile (make-chunk-mfile chunk-data mtype) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index idx + :content mfile})] + (t/is (nil? (:error out)))))) + session-id)) -(t/deftest ttf-font-upload-2 - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - proj-id (:default-project-id prof) - font-id (uuid/custom 10 1) - - data (-> (io/resource "backend_tests/test_files/font-1.woff") - (io/read*)) - - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 400 - :font-style "normal" - :data {"font/woff" data}} - out (th/command! params)] - - ;; (th/print-result! out) - (t/is (nil? (:error out))) - (let [result (:result out)] - (t/is (uuid? (:id result))) - (t/is (uuid? (:ttf-file-id result))) - (t/is (uuid? (:otf-file-id result))) - (t/is (uuid? (:woff1-file-id result))) - (t/are [k] (= (get params k) - (get result k)) - :team-id - :font-id - :font-family - :font-weight - :font-style)))) - -(t/deftest woff2-font-upload-1 - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - proj-id (:default-project-id prof) - font-id (uuid/custom 10 1) - - data (-> (io/resource "backend_tests/test_files/font-1.woff2") - (io/read*)) - - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 400 - :font-style "normal" - :data {"font/woff2" data}} - out (th/command! params)] - - ;; (th/print-result! out) - (t/is (nil? (:error out))) - (let [result (:result out)] - (t/is (uuid? (:id result))) - (t/is (uuid? (:ttf-file-id result))) - (t/is (uuid? (:otf-file-id result))) - (t/is (uuid? (:woff1-file-id result))) - (t/is (uuid? (:woff2-file-id result))) - (t/are [k] (= (get params k) - (get result k)) - :team-id - :font-id - :font-family - :font-weight - :font-style)))) +(defn- assert-font-variant-result + "Checks that a successful create-font-variant result has valid UUIDs and + the expected scalar fields matching `params`." + [params result] + (t/is (uuid? (:id result))) + (t/is (uuid? (:ttf-file-id result))) + (t/is (uuid? (:otf-file-id result))) + (t/is (uuid? (:woff1-file-id result))) + (t/are [k] (= (get params k) (get result k)) + :team-id + :font-id + :font-family + :font-weight + :font-style)) (t/deftest font-deletion-1 (let [prof (th/create-profile* 1 {:is-active true}) @@ -142,27 +111,29 @@ data2 (-> (io/resource "backend_tests/test_files/font-2.woff") (io/read*))] - ;; Create front variant - (let [params {::th/type :create-font-variant + ;; Create font variant + (let [session-id (upload-font-chunked! prof data1 "font/woff" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id font-id :font-family "somefont" :font-weight 400 :font-style "normal" - :data {"font/woff" data1}} + :uploads {"font/woff" session-id}} out (th/command! params)] ;; (th/print-result! out) (t/is (nil? (:error out)))) - (let [params {::th/type :create-font-variant + (let [session-id (upload-font-chunked! prof data2 "font/woff" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id font-id :font-family "somefont" :font-weight 500 :font-style "normal" - :data {"font/woff" data2}} + :uploads {"font/woff" session-id}} out (th/command! params)] ;; (th/print-result! out) (t/is (nil? (:error out)))) @@ -206,27 +177,29 @@ data2 (-> (io/resource "backend_tests/test_files/font-2.woff") (io/read*))] - ;; Create front variant - (let [params {::th/type :create-font-variant + ;; Create font variant + (let [session-id (upload-font-chunked! prof data1 "font/woff" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id font-id :font-family "somefont" :font-weight 400 :font-style "normal" - :data {"font/woff" data1}} + :uploads {"font/woff" session-id}} out (th/command! params)] ;; (th/print-result! out) (t/is (nil? (:error out)))) - (let [params {::th/type :create-font-variant + (let [session-id (upload-font-chunked! prof data2 "font/woff" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id (uuid/custom 10 2) :font-family "somefont" :font-weight 400 :font-style "normal" - :data {"font/woff" data2}} + :uploads {"font/woff" session-id}} out (th/command! params)] ;; (th/print-result! out) (t/is (nil? (:error out)))) @@ -265,12 +238,14 @@ font-id (uuid/custom 10 1) data1 (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) data2 (-> (io/resource "backend_tests/test_files/font-2.woff") (io/read*)) + sid1 (upload-font-chunked! prof data1 "font/woff" (* 4 1024 1024)) + sid2 (upload-font-chunked! prof data2 "font/woff" (* 4 1024 1024)) params1 {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id font-id :font-family "somefont" - :font-weight 400 :font-style "normal" :data {"font/woff" data1}} + :font-weight 400 :font-style "normal" :uploads {"font/woff" sid1}} params2 {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id font-id :font-family "somefont" - :font-weight 500 :font-style "normal" :data {"font/woff" data2}} + :font-weight 500 :font-style "normal" :uploads {"font/woff" sid2}} out1 (th/command! params1) out2 (th/command! params2)] (t/is (nil? (:error out1))) @@ -313,6 +288,7 @@ ttfdata (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + session-id (upload-font-chunked! prof ttfdata "font/ttf" (* 4 1024 1024)) params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id @@ -320,198 +296,14 @@ :font-family "somefont" :font-weight 400 :font-style "normal" - :data {"font/ttf" "/etc/passwd"}} + :uploads {"font/ttf" session-id}} out (th/command! params)] - (t/is (= 0 (:call-count @mock))) ;; (th/print-result! out) - - (let [error (:error out) - error-data (ex-data error)] - (t/is (th/ex-info? error)))))) + (t/is (nil? (:error out)))))) ;; ----------------------------------------------------------------------- -;; Helpers for chunked-upload font tests -;; ----------------------------------------------------------------------- - -(defn- split-bytes-into-chunks - "Splits `data` (byte array) into chunks of at most `chunk-size` bytes. - Returns a vector of byte arrays." - [^bytes data chunk-size] - (let [length (alength data)] - (loop [offset 0 chunks []] - (if (>= offset length) - chunks - (let [remaining (- length offset) - size (min chunk-size remaining) - buf (byte-array size)] - (System/arraycopy data offset buf 0 size) - (recur (+ offset size) (conj chunks buf))))))) - -(defn- make-chunk-mfile - "Writes `data` (byte array) to a tempfile and returns a map - compatible with the upload-chunk :content parameter." - [^bytes data mtype] - (let [tmp (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-font-chunk-")] - (io/write* tmp data) - {:filename "chunk" - :path tmp - :mtype mtype - :size (alength data)})) - -(defn- create-upload-session! - "Creates an upload session for `prof` with `total-chunks`. Returns the session-id UUID." - [prof total-chunks] - (let [out (th/command! {::th/type :create-upload-session - ::rpc/profile-id (:id prof) - :total-chunks total-chunks})] - (t/is (nil? (:error out))) - (:session-id (:result out)))) - -(defn- upload-font-chunked! - "Splits `font-bytes` into chunks of `chunk-size` bytes, creates an upload - session, uploads all chunks, and returns the session-id UUID." - [prof ^bytes font-bytes mtype chunk-size] - (let [chunks (split-bytes-into-chunks font-bytes chunk-size) - session-id (create-upload-session! prof (count chunks))] - (doseq [[idx chunk-data] (map-indexed vector chunks)] - (let [mfile (make-chunk-mfile chunk-data mtype) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index idx - :content mfile})] - (t/is (nil? (:error out))))) - session-id)) - -(defn- assert-font-variant-result - "Checks that a successful create-font-variant result has valid UUIDs and - the expected scalar fields matching `params`." - [params result] - (t/is (uuid? (:id result))) - (t/is (uuid? (:ttf-file-id result))) - (t/is (uuid? (:otf-file-id result))) - (t/is (uuid? (:woff1-file-id result))) - (t/are [k] (= (get params k) (get result k)) - :team-id - :font-id - :font-family - :font-weight - :font-style)) - -;; ----------------------------------------------------------------------- -;; Path 1 – Normal (direct :data bytes) -;; ----------------------------------------------------------------------- - -(t/deftest create-font-variant-normal-ttf - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 10) - data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "chunked-test" - :font-weight 400 - :font-style "normal" - :data {"font/ttf" data}} - out (th/command! params)] - (t/is (= 1 (:call-count @mock))) - (t/is (nil? (:error out))) - (assert-font-variant-result params (:result out))))) - -(t/deftest create-font-variant-normal-otf - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 11) - data (-> (io/resource "backend_tests/test_files/font-1.otf") (io/read*)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "chunked-test" - :font-weight 400 - :font-style "normal" - :data {"font/otf" data}} - out (th/command! params)] - (t/is (= 1 (:call-count @mock))) - (t/is (nil? (:error out))) - (assert-font-variant-result params (:result out))))) - -(t/deftest create-font-variant-normal-woff - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 12) - data (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "chunked-test" - :font-weight 400 - :font-style "normal" - :data {"font/woff" data}} - out (th/command! params)] - (t/is (= 1 (:call-count @mock))) - (t/is (nil? (:error out))) - (assert-font-variant-result params (:result out))))) - -;; ----------------------------------------------------------------------- -;; Path 2 – Legacy chunking (:data with vector of byte-arrays per mtype) -;; ----------------------------------------------------------------------- - -(t/deftest create-font-variant-legacy-chunked-ttf - "Upload a TTF via the legacy :data path where each mtype value is a - vector of byte-array chunks (4 MiB each) instead of a single byte-array." - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 20) - full-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) - ;; Simulate 4 MiB legacy chunks – font is small so a single chunk suffices - chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "legacy-chunked" - :font-weight 700 - :font-style "italic" - :data {"font/ttf" (vec chunks)}} - out (th/command! params)] - (t/is (= 1 (:call-count @mock))) - (t/is (nil? (:error out))) - (assert-font-variant-result params (:result out))))) - -(t/deftest create-font-variant-legacy-chunked-woff - "Upload a WOFF via the legacy :data path with multiple sub-4 KiB chunks - to exercise the SequenceInputStream concatenation path." - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 21) - full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) - ;; Split into small chunks to exercise the SequenceInputStream path - chunks (split-bytes-into-chunks full-bytes 512) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "legacy-chunked-woff" - :font-weight 400 - :font-style "normal" - :data {"font/woff" (vec chunks)}} - out (th/command! params)] - (t/is (= 1 (:call-count @mock))) - (t/is (nil? (:error out))) - (assert-font-variant-result params (:result out))))) - -;; ----------------------------------------------------------------------- -;; Path 3 – New standardized chunked upload (:uploads map) +;; Chunked upload (:uploads map) ;; ----------------------------------------------------------------------- (t/deftest create-font-variant-chunked-upload-ttf @@ -606,8 +398,8 @@ ;; Error cases ;; ----------------------------------------------------------------------- -(t/deftest create-font-variant-missing-data-and-uploads - "Neither :data nor :uploads is present — schema validation must reject it." +(t/deftest create-font-variant-missing-uploads + "Missing :uploads — schema validation must reject it." (let [prof (th/create-profile* 1 {:is-active true}) team-id (:default-team-id prof) font-id (uuid/custom 10 40) @@ -674,49 +466,6 @@ ;; Font size validation tests ;; ----------------------------------------------------------------------- -(t/deftest create-font-variant-size-exceeded-normal - "Direct :data upload exceeding font-max-file-size must be rejected." - (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] - (with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 50) - data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "size-exceeded" - :font-weight 400 - :font-style "normal" - :data {"font/ttf" data}} - out (th/command! params)] - (t/is (some? (:error out))) - (t/is (= :restriction (-> out :error ex-data :type))) - (t/is (= :font-max-file-size-reached (-> out :error ex-data :code))))))) - -(t/deftest create-font-variant-size-exceeded-legacy-chunked - "Legacy :data chunk-vector upload exceeding font-max-file-size must be rejected." - (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] - (with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 51) - full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) - chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "size-exceeded-legacy" - :font-weight 400 - :font-style "normal" - :data {"font/woff" (vec chunks)}} - out (th/command! params)] - (t/is (some? (:error out))) - (t/is (= :restriction (-> out :error ex-data :type))) - (t/is (= :font-max-file-size-reached (-> out :error ex-data :code))))))) - (t/deftest create-font-variant-size-exceeded-chunked-upload "New :uploads path exceeding font-max-file-size must be rejected after assembly." (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] @@ -738,72 +487,10 @@ (t/is (= :restriction (-> out :error ex-data :type))) (t/is (= :font-max-file-size-reached (-> out :error ex-data :code)))))))) -(t/deftest create-font-variant-size-within-limit - "Upload exactly at the limit must succeed." - (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 53) - font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) - font-size (alength ^bytes font-bytes)] - (with-redefs [app.config/config (assoc app.config/config :font-max-file-size font-size)] - (let [params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "size-at-limit" - :font-weight 400 - :font-style "normal" - :data {"font/ttf" font-bytes}} - out (th/command! params)] - (t/is (nil? (:error out))) - (assert-font-variant-result params (:result out))))))) - ;; ----------------------------------------------------------------------- -;; Font media-type validation tests +;; Font media-type validation ;; ----------------------------------------------------------------------- -(t/deftest create-font-variant-invalid-type-normal - "Direct :data upload with a disallowed mtype must be rejected." - (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 60) - data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "invalid-type" - :font-weight 400 - :font-style "normal" - :data {"application/octet-stream" data}} - out (th/command! params)] - (t/is (some? (:error out))) - (t/is (= :validation (-> out :error ex-data :type))) - (t/is (= :media-type-not-allowed (-> out :error ex-data :code)))))) - -(t/deftest create-font-variant-invalid-type-legacy-chunked - "Legacy :data chunk-vector upload with a disallowed mtype must be rejected." - (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 61) - full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) - chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "invalid-type-legacy" - :font-weight 400 - :font-style "normal" - :data {"image/png" (vec chunks)}} - out (th/command! params)] - (t/is (some? (:error out))) - (t/is (= :validation (-> out :error ex-data :type))) - (t/is (= :media-type-not-allowed (-> out :error ex-data :code)))))) - (t/deftest create-font-variant-invalid-type-chunked-upload "New :uploads path with a disallowed mtype must be rejected after assembly." (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] @@ -836,46 +523,50 @@ data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))] ;; name with < should fail - (let [params {::th/type :create-font-variant + (let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id font-id :font-family "evil<script>alert(1)</script>" :font-weight 400 :font-style "normal" - :data {"font/ttf" data}} + :uploads {"font/ttf" session-id}} out (th/command! params)] (t/is (not (th/success? out))) (t/is (th/ex-of-type? (:error out) :validation)) (t/is (th/ex-of-code? (:error out) :params-validation))) ;; name with ' should fail - (let [params {::th/type :create-font-variant + (let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id font-id :font-family "evil'name" :font-weight 400 :font-style "normal" - :data {"font/ttf" data}} + :uploads {"font/ttf" session-id}} out (th/command! params)] (t/is (not (th/success? out))) (t/is (th/ex-of-type? (:error out) :validation))) ;; name with } should fail - (let [params {::th/type :create-font-variant + (let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id font-id :font-family "evil}name" :font-weight 400 :font-style "normal" - :data {"font/ttf" data}} + :uploads {"font/ttf" session-id}} out (th/command! params)] (t/is (not (th/success? out))) (t/is (th/ex-of-type? (:error out) :validation))) ;; valid name should succeed - (let [params {::th/type :create-font-variant + (let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id (uuid/custom 10 101) :font-family "Source Sans Pro" :font-weight 400 :font-style "normal" - :data {"font/ttf" data}} + :uploads {"font/ttf" session-id}} out (th/command! params)] (t/is (th/success? out)))))) @@ -887,12 +578,13 @@ data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))] ;; Create a valid font first - (let [params {::th/type :create-font-variant + (let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id font-id :font-family "ValidFont" :font-weight 400 :font-style "normal" - :data {"font/ttf" data}} + :uploads {"font/ttf" session-id}} out (th/command! params)] (t/is (th/success? out))) @@ -922,3 +614,65 @@ :name "Valid Font Name"} out (th/command! params)] (t/is (th/success? out)))))) + +(t/deftest create-font-variant-rejects-foreign-font-id + ;; N2-07: A user with edit permissions on their own team must not be + ;; able to create a font variant using a font-id that already belongs + ;; to another team (BOLA / CWE-639). + (let [prof1 (th/create-profile* 1 {:is-active true}) + prof2 (th/create-profile* 2 {:is-active true}) + team1 (:default-team-id prof1) + team2 (:default-team-id prof2) + font-id (uuid/custom 10 999) + data (-> (io/resource "backend_tests/test_files/font-1.ttf") + (io/read*))] + + ;; prof1 creates a font variant in team1 with font-id + (let [session-id (upload-font-chunked! prof1 data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof1) + :team-id team1 + :font-id font-id + :font-family "SharedFont" + :font-weight 400 + :font-style "normal" + :uploads {"font/ttf" session-id}} + out (th/command! params)] + (t/is (nil? (:error out)))) + + ;; prof2 tries to create a variant using the same font-id but + ;; in team2, which must be rejected because font-id belongs to team1 + (let [session-id (upload-font-chunked! prof2 data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof2) + :team-id team2 + :font-id font-id + :font-family "SharedFont" + :font-weight 700 + :font-style "normal" + :uploads {"font/ttf" session-id}} + out (th/command! params)] + (t/is (some? (:error out))) + (t/is (= :not-found (-> out :error ex-data :type))) + (t/is (= :object-not-found (-> out :error ex-data :code)))))) + +(t/deftest get-font-variants-nonexistent-file + (let [prof (th/create-profile* 1 {:is-active true}) + out (th/command! {::th/type :get-font-variants + ::rpc/profile-id (:id prof) + :file-id (uuid/random)}) + err (:error out)] + (t/is (th/ex-info? err)) + (t/is (th/ex-of-type? err :not-found)))) + +(t/deftest get-font-variants-no-permission + (let [owner (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:default-project-id owner)}) + out (th/command! {::th/type :get-font-variants + ::rpc/profile-id (:id other) + :file-id (:id file)}) + err (:error out)] + (t/is (th/ex-info? err)) + (t/is (th/ex-of-type? err :not-found)))) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 769b5ec535..7857bdc1a5 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-management-nitrate-test (:require @@ -50,45 +50,138 @@ (t/is (= :authentication-required (th/ex-code (:error out)))))) (t/deftest create-and-update-organization-invitations-audit-props + (let [owner-id-ref (atom nil)] + (with-mocks [email-mock {:target 'app.email/send! :return nil} + audit-mock {:target 'app.loggers.audit/submit :return nil} + nitrate-mock {:target 'app.nitrate/call + :return (fn [_cfg method params] + (when (= method :get-organization-summary) + {:id (:organization-id params) + :name "Acme" + :owner-id @owner-id-ref + :teams []}))}] + (binding [cf/flags (conj cf/flags :email-verification)] + (let [owner (th/create-profile* 101 {:is-active true}) + invitee (th/create-profile* 102 {:is-active true}) + organization {:id (uuid/random) + :name "Acme" + :initials "AC" + :logo nil + :avatar-bg-url nil} + _ (reset! owner-id-ref (:id owner)) + params {::th/type :invite-to-organization + ::rpc/profile-id (:id owner) + :email (:email invitee) + :organization organization} + create-out (th/management-command! params) + update-out (th/management-command! params) + external-out (th/management-command! (assoc params :email "external@example.com")) + events (mapv second (:call-args-list @audit-mock)) + create-event (first (filter #(= "create-organization-invitation" (:name %)) events)) + update-event (first (filter #(= "update-organization-invitation" (:name %)) events)) + external-event + (first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))] + (t/is (th/success? create-out)) + (t/is (th/success? update-out)) + (t/is (th/success? external-out)) + + (doseq [event [create-event update-event]] + (t/is (not (contains? (:props event) :event-origin))) + (t/is (= (str (:id owner)) + (get-in event [:props :user-who-send-invitation]))) + (t/is (= (:id organization) + (get-in event [:props :organization-id]))) + (t/is (= (:email invitee) + (get-in event [:props :member-email]))) + (t/is (= (:id invitee) + (get-in event [:props :member-id])))) + + (t/is (not (contains? (:props external-event) :member-id)))))))) + +(t/deftest invite-to-organization-rejects-non-owner + (let [organization-summary-ref (atom nil)] + (with-mocks [email-mock {:target 'app.email/send! :return nil} + nitrate-mock {:target 'app.nitrate/call + :return (fn [_cfg method _params] + (when (= method :get-organization-summary) + @organization-summary-ref))}] + (let [owner (th/create-profile* 103 {:is-active true}) + attacker (th/create-profile* 104 {:is-active true}) + organization-id (uuid/random) + organization {:id organization-id + :name "Trusted Organization" + :initials "TO" + :logo nil + :avatar-bg-url nil} + _ (reset! organization-summary-ref + {:id organization-id + :name "Trusted Organization" + :owner-id (:id owner) + :teams []}) + out (th/management-command! {::th/type :invite-to-organization + ::rpc/profile-id (:id attacker) + :email "victim@example.com" + :organization organization})] + (t/is (not (th/success? out))) + (t/is (= :not-found (th/ex-type (:error out)))) + (t/is (= :object-not-found (th/ex-code (:error out)))) + (t/is (not (:called? @email-mock))))))) + +(t/deftest invite-to-organization-rejects-unknown-organization (with-mocks [email-mock {:target 'app.email/send! :return nil} - audit-mock {:target 'app.loggers.audit/submit :return nil} nitrate-mock {:target 'app.nitrate/call :return nil}] - (binding [cf/flags (conj cf/flags :email-verification)] - (let [owner (th/create-profile* 101 {:is-active true}) - invitee (th/create-profile* 102 {:is-active true}) - organization {:id (uuid/random) - :name "Acme" - :initials "AC" - :logo nil - :avatar-bg-url nil} - params {::th/type :invite-to-organization - ::rpc/profile-id (:id owner) - :email (:email invitee) - :organization organization} - create-out (th/management-command! params) - update-out (th/management-command! params) - external-out (th/management-command! (assoc params :email "external@example.com")) - events (mapv second (:call-args-list @audit-mock)) - create-event (first (filter #(= "create-organization-invitation" (:name %)) events)) - update-event (first (filter #(= "update-organization-invitation" (:name %)) events)) - external-event - (first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))] - (t/is (th/success? create-out)) - (t/is (th/success? update-out)) - (t/is (th/success? external-out)) + (let [profile (th/create-profile* 105 {:is-active true}) + organization-id (uuid/random) + out (th/management-command! {::th/type :invite-to-organization + ::rpc/profile-id (:id profile) + :email "victim@example.com" + :organization {:id organization-id + :name "Fabricated Organization" + :initials "FO" + :logo "https://evil.example/logo.png" + :avatar-bg-url nil}})] + (t/is (not (th/success? out))) + (t/is (= :not-found (th/ex-type (:error out)))) + (t/is (= :object-not-found (th/ex-code (:error out)))) + (t/is (not (:called? @email-mock)))))) - (doseq [event [create-event update-event]] - (t/is (not (contains? (:props event) :event-origin))) - (t/is (= (str (:id owner)) - (get-in event [:props :user-who-send-invitation]))) - (t/is (= (:id organization) - (get-in event [:props :organization-id]))) - (t/is (= (:email invitee) - (get-in event [:props :member-email]))) - (t/is (= (:id invitee) - (get-in event [:props :member-id])))) - - (t/is (not (contains? (:props external-event) :member-id))))))) +(t/deftest invite-to-organization-uses-authoritative-branding + (let [organization-summary-ref (atom nil)] + (with-mocks [email-mock {:target 'app.email/send! :return nil} + nitrate-mock {:target 'app.nitrate/call + :return (fn [_cfg method _params] + (when (= method :get-organization-summary) + @organization-summary-ref))}] + (binding [cf/flags (conj cf/flags :email-verification)] + (let [owner (th/create-profile* 106 {:is-active true}) + organization-id (uuid/random) + logo-id (uuid/random) + _ (reset! organization-summary-ref + {:id organization-id + :name "Trusted Organization" + :owner-id (:id owner) + :logo-id logo-id + :avatar-bg-url "https://trusted.example/avatar.svg" + :sso-active true + :teams []}) + out (th/management-command! {::th/type :invite-to-organization + ::rpc/profile-id (:id owner) + :email "victim@example.com" + :organization {:id organization-id + :name "Fabricated Bank" + :initials "FB" + :logo "https://evil.example/logo.png" + :avatar-bg-url "https://evil.example/avatar.svg" + :sso-active false}}) + email-params (first (:call-args @email-mock)) + organization (:organization email-params)] + (t/is (th/success? out)) + (t/is (= "Trusted Organization" (:name organization))) + (t/is (= "" (:initials organization))) + (t/is (str/ends-with? (str (:logo organization)) + (str "/assets/by-id/" logo-id))) + (t/is (nil? (:avatar-bg-url organization))) + (t/is (true? (:sso-active organization)))))))) (t/deftest get-penpot-version (let [out (th/management-command! {::th/type :get-penpot-version}) @@ -101,6 +194,15 @@ (string? (get version k))))) (t/is (= cf/version version)))) +(t/deftest get-air-gapped + (let [out (th/management-command! {::th/type :get-air-gapped})] + (t/is (th/success? out)) + (t/is (false? (-> out :result :air-gapped)))) + (binding [cf/flags (conj cf/flags :air-gapped-conf)] + (let [out (th/management-command! {::th/type :get-air-gapped})] + (t/is (th/success? out)) + (t/is (true? (-> out :result :air-gapped)))))) + (t/deftest get-teams-returns-only-owned-non-default-non-deleted (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] (let [profile (th/create-profile* 1 {:is-active true}) @@ -175,7 +277,7 @@ new-team (th/db-get :team {:id new-team-id})] (t/is (th/success? out)) (t/is (= 1 (count (set/difference after-teams before-teams)))) - (t/is (= "Your Penpot" (:name new-team))) + (t/is (= "Personal Projects" (:name new-team))) (t/is (true? (:is-default new-team)))))) (t/deftest get-managed-profiles-returns-unique-members-for-owned-teams diff --git a/backend/test/backend_tests/rpc_management_test.clj b/backend/test/backend_tests/rpc_management_test.clj index 601e8b3d35..1148fde042 100644 --- a/backend/test/backend_tests/rpc_management_test.clj +++ b/backend/test/backend_tests/rpc_management_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-management-test (:require @@ -11,6 +11,7 @@ [app.common.pprint :as pp] [app.common.types.shape :as cts] [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as db] [app.http :as http] [app.rpc :as-alias rpc] @@ -19,6 +20,7 @@ [backend-tests.storage-test :refer [configure-storage-backend]] [buddy.core.bytes :as b] [clojure.test :as t] + [cuerdas.core :as str] [datoteka.fs :as fs] [datoteka.io :as io])) @@ -50,13 +52,34 @@ :path path :mtype "image/png" :size 7}} - out1 (th/management-command! params) - out2 (th/management-command! params)] + config (assoc cf/config :public-uri "https://example.com/penpot") + out1 (binding [cf/config config] + (th/management-command! params)) + out2 (binding [cf/config config] + (th/management-command! params))] (t/is (nil? (:error out1))) (t/is (nil? (:error out2))) + (t/is (str/starts-with? (str (get-in out1 [:result :uri])) + "https://example.com/penpot/assets/by-id/")) (t/is (not= (get-in out1 [:result :id]) (get-in out2 [:result :id]))))) +(t/deftest upload-tempfile-rejects-html-content-type + ;; N2-13: upload-tempfile must reject non-allowed content types + (let [profile (th/create-profile* 1 {:is-active true}) + path (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-upload-tempfile-") + _ (io/write* path "<script>alert(1)</script>") + params {::th/type :upload-tempfile + ::rpc/profile-id (:id profile) + :content {:filename "evil.html" + :path path + :mtype "text/html" + :size 27}} + out (th/management-command! params)] + (t/is (some? (:error out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :media-type-not-allowed (th/ex-code (:error out)))))) + (t/deftest duplicate-file (let [storage (-> (:app.storage/storage th/*system*) (configure-storage-backend)) diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index ff38aee470..75db78c06b 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-media-test (:require @@ -380,8 +380,41 @@ (t/is (= :validation (:type (ex-data err)))) (t/is (= :unable-to-download-image (:code (ex-data err)))))))) -;; -------------------------------------------------------------------- -;; Helpers for chunked-upload tests + +(t/deftest download-image-closes-stream + (t/testing "response body stream is closed on success" + (let [closed? (atom false) + ;; Minimal valid PNG (1x1 pixel, red) + png-data (byte-array [0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A 0x00 0x00 0x00 0x0D 0x49 0x48 0x44 0x52 0x00 0x00 0x00 0x01 0x00 0x00 0x00 0x01 0x08 0x02 0x00 0x00 0x00 0x90 0x77 0x53 0xDE 0x00 0x00 0x00 0x0C 0x49 0x44 0x41 0x54 0x08 0xD7 0x63 0xF8 0xCF 0xC0 0x00 0x00 0x00 0x02 0x00 0x01 0xE2 0x21 0xBC 0x33 0x00 0x00 0x00 0x00 0x49 0x45 0x4E 0x44 0xAE 0x42 0x60 0x82]) + body (proxy [java.io.ByteArrayInputStream] [png-data] + (close [] (reset! closed? true)))] + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects + :return {:status 200 + :headers {"content-type" "image/png" + "content-length" (str (alength png-data))} + :body body}}] + (let [cfg {::http/client :mock-client} + result (media/download-image cfg "https://example.com/image.png")] + (t/is (some? result)) + (t/is @closed? "body stream should be closed after successful download"))))) + + (t/testing "response body stream is closed on validation error" + (let [closed? (atom false) + body (proxy [java.io.ByteArrayInputStream] [(byte-array 100)] + (close [] (reset! closed? true)))] + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects + :return {:status 404 + :headers {"content-type" "text/html" + "content-length" "100"} + :body body}}] + (let [cfg {::http/client :mock-client} + err (try + (media/download-image cfg "https://example.com/not-found.png") + nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? err)) + (t/is (= :unable-to-download-image (:code (ex-data err)))) + (t/is @closed? "body stream should be closed even on validation error")))))) ;; -------------------------------------------------------------------- (defn- split-file-into-chunks @@ -548,6 +581,41 @@ (t/is (some? (:error out))) (t/is (= :not-found (-> out :error ex-data :type))))) +(t/deftest chunked-upload-other-profile-cannot-assemble + ;; assemble-chunks must scope the session lookup to the requesting + ;; profile so that a different profile cannot assemble chunks from + ;; a session they do not own (BOLA / CWE-639). + (let [prof1 (th/create-profile* 1) + prof2 (th/create-profile* 2) + session-id (create-session! prof1 1) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + mfile {:filename "sample.jpg" + :path source-path + :mtype "image/jpeg" + :size 312043}] + + ;; prof1 uploads a chunk into their own session + (let [out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof1) + :session-id session-id + :index 0 + :content mfile})] + (t/is (nil? (:error out)))) + + ;; prof2 tries to assemble prof1's session via create-font-variant + ;; (which calls assemble-chunks without ownership check) + (let [out (th/command! {::th/type :create-font-variant + ::rpc/profile-id (:id prof2) + :team-id (:default-team-id prof2) + :font-id (uuid/next) + :font-family "TestFont" + :font-weight 400 + :font-style "normal" + :uploads {"font/ttf" session-id}})] + (t/is (some? (:error out))) + (t/is (= :not-found (-> out :error ex-data :type))) + (t/is (= :object-not-found (-> out :error ex-data :code)))))) + (t/deftest chunked-upload-invalid-media-type (let [prof (th/create-profile* 1) _ (th/create-project* 1 {:profile-id (:id prof) @@ -650,6 +718,24 @@ (t/is (= :max-quote-reached (-> out :error ex-data :code))) (t/is (= "upload-chunks-per-session" (-> out :error ex-data :target)))))) +(t/deftest chunked-upload-invalid-total-chunks + ;; total-chunks must be at least 1; zero and negative values are rejected + ;; with a :validation error. + (let [prof (th/create-profile* 1)] + ;; zero total-chunks + (let [out (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 0})] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type)))) + + ;; negative total-chunks + (let [out (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks -1})] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type)))))) + (t/deftest chunked-upload-invalid-chunk-index ;; Both a negative index and an index >= total-chunks must be ;; rejected with a :validation / :invalid-chunk-index error. @@ -701,3 +787,98 @@ (t/is (some? (:error out))) (t/is (= :restriction (-> out :error ex-data :type))) (t/is (= :max-quote-reached (-> out :error ex-data :code))))))) + +;; --- Clone File Media Object BOLA tests --- + +(defn- create-storage-object! + [content content-type] + (let [storage (:app.storage/storage th/*system*)] + (sto/put-object! storage {::sto/content (sto/content content) + :content-type content-type}))) + +(t/deftest clone-file-media-object-success + (let [prof1 (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof1) + :team-id (:default-team-id prof1)}) + file1 (th/create-file* 1 {:profile-id (:id prof1) + :project-id (:default-project-id prof1) + :is-shared false}) + sobj (create-storage-object! "image-content" "image/png") + mobj (th/create-file-media-object* {:file-id (:id file1) + :name "test-media" + :width 100 + :height 100 + :mtype "image/png" + :media-id (:id sobj)}) + file2 (th/create-file* 2 {:profile-id (:id prof1) + :project-id (:default-project-id prof1) + :is-shared false}) + params {::th/type :clone-file-media-object + ::rpc/profile-id (:id prof1) + :file-id (:id file2) + :is-local true + :id (:id mobj)} + out (th/command! params)] + + (t/is (nil? (:error out))) + (let [result (:result out)] + (t/is (= (:id file2) (:file-id result))) + (t/is (= (:name mobj) (:name result))) + (t/is (= (:media-id mobj) (:media-id result))) + (t/is (uuid? (:id result))) + (t/is (not= (:id mobj) (:id result)))))) + +(t/deftest clone-file-media-object-no-read-access + (let [prof1 (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof1) + :team-id (:default-team-id prof1)}) + file1 (th/create-file* 1 {:profile-id (:id prof1) + :project-id (:default-project-id prof1) + :is-shared false}) + sobj (create-storage-object! "private-content" "image/png") + mobj (th/create-file-media-object* {:file-id (:id file1) + :name "private-media" + :width 100 + :height 100 + :mtype "image/png" + :media-id (:id sobj)}) + + prof2 (th/create-profile* 2) + _ (th/create-project* 2 {:profile-id (:id prof2) + :team-id (:default-team-id prof2)}) + file2 (th/create-file* 2 {:profile-id (:id prof2) + :project-id (:default-project-id prof2) + :is-shared false}) + + params {::th/type :clone-file-media-object + ::rpc/profile-id (:id prof2) + :file-id (:id file2) + :is-local true + :id (:id mobj)} + out (th/command! params)] + + (let [error (:error out) + error-data (ex-data error)] + (t/is (th/ex-info? error)) + (t/is (= :not-found (:type error-data))) + (t/is (= :object-not-found (:code error-data)))))) + +(t/deftest clone-file-media-object-source-not-found + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + params {::th/type :clone-file-media-object + ::rpc/profile-id (:id prof) + :file-id (:id file) + :is-local true + :id (uuid/random)} + out (th/command! params)] + + (let [error (:error out) + error-data (ex-data error)] + (t/is (th/ex-info? error)) + (t/is (= :not-found (:type error-data))) + (t/is (= :object-not-found (:code error-data)))))) diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj index 5d973d94bd..936e354910 100644 --- a/backend/test/backend_tests/rpc_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -2,11 +2,12 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-nitrate-test (:require [app.auth.oidc :as oidc] + [app.common.exceptions :as ex] [app.common.json :as json] [app.common.time :as ct] [app.common.uuid :as uuid] @@ -14,15 +15,17 @@ [app.db :as-alias db] [app.email :as eml] [app.http :as-alias http] + [app.http.errors :as http-errors] [app.nitrate :as nitrate] - [app.rpc :as-alias rpc] + [app.rpc :as rpc] [app.rpc.commands.nitrate] [app.rpc.commands.teams :as teams] [app.rpc.helpers :as rph] [backend-tests.helpers :as th] [buddy.core.codecs :as bc] [clojure.test :as t] - [cuerdas.core :as str])) + [cuerdas.core :as str] + [yetti.response :as-alias yres])) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -86,6 +89,31 @@ nil))) +(defn- unauthorized-sso-mock + "Creates a mock for nitrate/sso-session-authorized? that reports an active + SSO the session does not satisfy. Pass nil to leave the organization out of + the nitrate payload." + [organization-id] + (fn [_cfg _organization-id _team-id _request] + {:authorized false + :sso (cond-> {:active true + :issuer "https://idp.example.com"} + (some? organization-id) + (assoc :organization-id organization-id))})) + +(defn- sso-gate-error + "Builds the SSO gate around a handler that must never be reached, and + returns the exception it raises for `params`." + [mdata params cfg] + (let [handler (fn [_cfg _params] ::handler-called) + wrapped (binding [cf/flags (conj cf/flags :admin-console)] + (#'rpc/wrap-nitrate-sso nil handler mdata))] + (try + (wrapped cfg (with-meta params {::http/request {}})) + nil + (catch Throwable cause + cause)))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Tests ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -111,13 +139,17 @@ (constantly "https://idp.example.com/authorize")] (let [out (th/command! params)] (t/is (th/success? out)) - (t/is (= {:authorized true} (:result out)))))))) + ;; The reason tells the client this is a permission problem, not a + ;; usable SSO session. + (t/is (= {:authorized true :reason :no-team-access} (:result out)))))))) (t/deftest check-nitrate-sso-keeps-gate-for-team-member (let [team-owner (th/create-profile* 1 {:is-active true}) team (th/create-team* 1 {:profile-id (:id team-owner)}) organization-id (uuid/random) redirect-uri "https://idp.example.com/authorize" + redirect-options (atom nil) + started-event (atom nil) params (with-meta {::th/type :check-nitrate-sso ::rpc/profile-id (:id team-owner) @@ -131,12 +163,50 @@ organization-id (:id team-owner)) oidc/build-organization-sso-auth-redirect-uri - (constantly redirect-uri)] + (fn [_cfg _sso & options] + (reset! redirect-options (apply hash-map options)) + redirect-uri) + oidc/submit-organization-sso-auth-started-event + (fn [_cfg _request profile-id received-organization-id] + (reset! started-event {:profile-id profile-id + :organization-id received-organization-id}))] (let [out (th/command! params)] (t/is (th/success? out)) (t/is (= {:authorized false :redirect-uri redirect-uri} - (:result out)))))))) + (:result out))) + (t/is (= #{:dest-url :organization-id} (set (keys @redirect-options)))) + (t/is (= "https://penpot.example.com/#/workspace" (str (:dest-url @redirect-options)))) + (t/is (nil? (:organization-id @redirect-options))) + (t/is (= {:profile-id (:id team-owner) + :organization-id organization-id} + @started-event))))))) + +(t/deftest check-nitrate-sso-reports-redirect-failure + (let [profile (th/create-profile* 1 {:is-active true}) + organization-id (uuid/random) + cause (ex-info "provider unavailable" {:response-status-code 503}) + reported (atom nil) + params (with-meta + {::th/type :check-nitrate-sso + ::rpc/profile-id (:id profile) + :organization-id organization-id + :url "https://penpot.example.com/#/workspace"} + {::http/request {}})] + (binding [cf/flags (conj cf/flags :admin-console)] + (with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock organization-id) + oidc/build-organization-sso-auth-redirect-uri (fn [& _] (throw cause)) + oidc/submit-organization-sso-auth-failed-event + (fn [_cfg _request profile-id received-organization-id received-cause] + (reset! reported {:profile-id profile-id + :organization-id received-organization-id + :cause received-cause}))] + (let [out (th/command! params)] + (t/is (not (th/success? out))) + (t/is (= {:profile-id (:id profile) + :organization-id organization-id + :cause cause} + @reported))))))) (t/deftest check-nitrate-sso-keeps-gate-for-non-member-organization-owner (let [team-owner (th/create-profile* 1 {:is-active true}) @@ -168,6 +238,91 @@ :redirect-uri redirect-uri} (:result out)))))))) +(t/deftest check-nitrate-sso-reports-a-satisfied-gate-for-a-valid-session + (let [team-owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id team-owner)}) + organization-id (uuid/random) + params (with-meta + {::th/type :check-nitrate-sso + ::rpc/profile-id (:id team-owner) + :team-id (:id team) + :url "https://penpot.example.com/#/workspace"} + {::http/request {}})] + (binding [cf/flags (conj cf/flags :admin-console)] + (with-redefs [nitrate/sso-session-authorized? + (fn [_cfg _organization-id _team-id _request] + {:authorized true + :sso {:active true + :issuer "https://idp.example.com" + :organization-id organization-id}})] + (let [out (th/command! params)] + (t/is (th/success? out)) + (t/is (= {:authorized true :reason :sso-satisfied} (:result out)))))))) + +(t/deftest nitrate-sso-required-error-resolves-the-team-from-the-file + (t/testing "the workspace path, where the file id arrives as :id, still reports the team" + (let [profile (th/create-profile* 1 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile)}) + organization-id (uuid/random)] + (with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock organization-id)] + (let [data (ex-data (sso-gate-error {::rpc/id-type :file} + {::rpc/profile-id (:id profile) + :id (:id file)} + th/*system*))] + (t/is (= :authentication (:type data))) + (t/is (= :nitrate-sso-required (:code data))) + (t/is (= organization-id (:organization-id data))) + (t/is (= (:default-team-id profile) (:team-id data)))))))) + +(t/deftest nitrate-sso-required-error-keeps-the-team-known-by-the-request + (t/testing "an explicit team-id is not dropped by an explicit organization-id" + (let [profile-id (uuid/random) + team-id (uuid/random) + organization-id (uuid/random)] + ;; The nitrate payload carries no organization-id here, so the one from + ;; the request params is the only one left to report. + (with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock nil)] + (let [data (ex-data (sso-gate-error {} + {::rpc/profile-id profile-id + :team-id team-id + :organization-id organization-id} + {}))] + (t/is (= organization-id (:organization-id data))) + (t/is (= team-id (:team-id data)))))))) + +(t/deftest nitrate-sso-required-error-resolves-the-team-with-a-known-organization + (t/testing "knowing the organization does not stop the team lookup" + (let [profile (th/create-profile* 1 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile)}) + organization-id (uuid/random)] + (with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock nil)] + (let [data (ex-data (sso-gate-error {} + {::rpc/profile-id (:id profile) + :organization-id organization-id + :file-id (:id file)} + th/*system*))] + (t/is (= organization-id (:organization-id data))) + (t/is (= (:default-team-id profile) (:team-id data)))))))) + +(t/deftest nitrate-sso-required-error-reaches-the-client-in-the-401-body + (t/testing "the ids survive the http error response, not only the exception" + (let [profile-id (uuid/random) + team-id (uuid/random) + organization-id (uuid/random)] + (with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock organization-id)] + (let [cause (sso-gate-error {} + {::rpc/profile-id profile-id + :team-id team-id} + {}) + response (http-errors/handle cause {}) + body (::yres/body response)] + (t/is (= 401 (::yres/status response))) + (t/is (= :nitrate-sso-required (:code body))) + (t/is (= organization-id (:organization-id body))) + (t/is (= team-id (:team-id body)))))))) + (t/deftest leave-organization-happy-path-no-extra-teams (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) @@ -1013,12 +1168,68 @@ @set-team-params)) (let [emails (->> @sent (map :to) set)] - (t/is (= 2 (count @sent))) - (t/is (= #{"member302@example.com" "external301@example.com"} emails)) + (t/is (= 1 (count @sent))) + (t/is (= #{"member302@example.com"} emails)) (doseq [email-params @sent] (t/is (= organization-name (:organization-name email-params))) (t/is (= eml/organization-setup-sso (::eml/factory email-params))))))) +(t/deftest add-team-to-organization-deletes-external-invitations-for-unregistered-users + (let [owner (th/create-profile* 305 {:is-active true + :fullname "Owner" + :email "owner305@example.com"}) + member (th/create-profile* 306 {:is-active true + :fullname "Member" + :email "member306@example.com"}) + team (th/create-team* 305 {:profile-id (:id owner)}) + _ (th/create-team-role* {:team-id (:id team) + :profile-id (:id member) + :role :editor}) + organization-id (uuid/random) + organization-summary {:id organization-id + :name "Test Org" + :owner-id (:id owner) + :teams []} + organization-perms {:owner-id (:id owner) + :permissions {:create-teams "any" + :move-teams "always" + :new-team-members "members"}}] + + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team) + :org-id nil + :email-to "unregistered@example.com" + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "48h")}) + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team) + :org-id nil + :email-to "unregistered2@example.com" + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "48h")}) + + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call (add-team-to-organization-nitrate-mock + {:organization-id organization-id + :organization-summary organization-summary + :organization-perms organization-perms + :owner-id (:id owner) + :team-id (:id team) + :sso-active? false}) + teams/initialize-user-in-organization (fn [& _] nil)] + (let [out (th/command! {::th/type :add-team-to-organization + ::rpc/profile-id (:id owner) + :team-id (:id team) + :organization-id organization-id})] + (t/is (th/success? out)))) + + (let [remaining (th/db-query :team-invitation {:team-id (:id team)})] + (t/is (empty? remaining) "Both external invitations should be deleted")))) + (t/deftest create-team-in-organization-passes-association-to-nitrate (let [organization-id (uuid/random) team {:id (uuid/random) @@ -1123,3 +1334,45 @@ ::rpc/profile-id (:id profile)})] (t/is (not (th/success? out))) (t/is (th/ex-of-code? (:error out) :nitrate-identity-unavailable)))))) + +(t/deftest redeem-nitrate-activation-code-used + (let [profile (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call (fn [_cfg method _params] + (t/is (= :redeem-activation-code method)) + (ex/raise :type :nitrate-http-error + :status 409 + :hint "activation code already used"))] + (let [out (th/command! {::th/type :redeem-nitrate-activation-code + ::rpc/profile-id (:id profile) + :activation-code "already-used-code"})] + (t/is (not (th/success? out))) + (t/is (th/ex-of-code? (:error out) :used-activation-code)))))) + +(t/deftest redeem-nitrate-activation-code-expired + (let [profile (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call (fn [_cfg method _params] + (t/is (= :redeem-activation-code method)) + (ex/raise :type :nitrate-http-error + :status 410 + :hint "activation code expired"))] + (let [out (th/command! {::th/type :redeem-nitrate-activation-code + ::rpc/profile-id (:id profile) + :activation-code "expired-code"})] + (t/is (not (th/success? out))) + (t/is (th/ex-of-code? (:error out) :expired-activation-code)))))) + +(t/deftest redeem-nitrate-activation-code-invalid + (let [profile (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call (fn [_cfg method _params] + (t/is (= :redeem-activation-code method)) + (ex/raise :type :nitrate-http-error + :status 422 + :hint "invalid activation code"))] + (let [out (th/command! {::th/type :redeem-nitrate-activation-code + ::rpc/profile-id (:id profile) + :activation-code "invalid-code"})] + (t/is (not (th/success? out))) + (t/is (th/ex-of-code? (:error out) :invalid-activation-code)))))) diff --git a/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj b/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj index 8168242f16..13c7f8120f 100644 --- a/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj +++ b/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-organization-owner-permissions-test (:require @@ -50,7 +50,16 @@ :organization (organization-data organization-id organization-owner-id)} {:id (:team-id params) :is-your-penpot false - :organization nil})))] + :organization nil}) + + :get-teams-organizations + (->> (:team-ids params) + (keep (fn [candidate-team-id] + (when (= team-id candidate-team-id) + {:id team-id + :is-your-penpot false + :organization (organization-data organization-id organization-owner-id)}))) + vec)))] (f))) (defn- with-captured-messages diff --git a/backend/test/backend_tests/rpc_plugins_test.clj b/backend/test/backend_tests/rpc_plugins_test.clj new file mode 100644 index 0000000000..9850d90ba9 --- /dev/null +++ b/backend/test/backend_tests/rpc_plugins_test.clj @@ -0,0 +1,162 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.rpc-plugins-test + (:require + [app.common.uuid :as uuid] + [app.rpc :as-alias rpc] + [app.rpc.commands.profile :as profile] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(def ^:private plugin-id-1 (str (uuid/next))) +(def ^:private plugin-id-2 (str (uuid/next))) + +(def ^:private valid-plugin + {:plugin-id plugin-id-1 + :name "Test Plugin" + :description "A test plugin" + :host "https://example.com" + :code "(function() { console.log('hello'); })()" + :icon "icon.svg" + :permissions #{"content:read" "content:write"}}) + +(t/deftest add-profile-plugin-accepts-valid-permissions + (let [profile (th/create-profile* 1) + data {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin valid-plugin} + out (th/command! data)] + + (t/is (nil? (:error out))) + (t/is (some? (:result out))) + + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved) + plugins (get-in props [:props :plugins])] + (t/is (= [plugin-id-1] (:ids plugins))) + (t/is (= valid-plugin (get-in plugins [:data plugin-id-1])))))) + +(t/deftest add-profile-plugin-rejects-invalid-permissions + (let [profile (th/create-profile* 1) + plugin (assoc valid-plugin :permissions #{"content:read" "admin:delete"}) + data {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin plugin} + out (th/command! data)] + + ;; Schema validation catches invalid permissions before custom validation + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation)) + + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved) + plugins (get-in props [:props :plugins])] + (t/is (nil? plugins) "No plugins should be persisted when validation fails")))) + +(t/deftest add-profile-plugin-updates-existing-plugin + (let [profile (th/create-profile* 1) + data1 {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin valid-plugin} + _ (th/command! data1) + + updated-plugin (assoc valid-plugin :name "Updated Plugin") + data2 {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin updated-plugin} + out (th/command! data2)] + + (t/is (nil? (:error out))) + + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved) + plugins (get-in props [:props :plugins])] + (t/is (= 1 (count (:ids plugins))) "Should still have only one plugin") + (t/is (= "Updated Plugin" (get-in plugins [:data plugin-id-1 :name])))))) + +(t/deftest remove-profile-plugin-removes-plugin + (let [profile (th/create-profile* 1) + data1 {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin valid-plugin} + _ (th/command! data1) + + data2 {::th/type :remove-profile-plugin + ::rpc/profile-id (:id profile) + :plugin-id (uuid/uuid plugin-id-1)} + out (th/command! data2)] + + (t/is (nil? (:error out))) + + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved) + plugins (get-in props [:props :plugins])] + (t/is (= [] (:ids plugins))) + (t/is (empty? (:data plugins)))))) + +(t/deftest remove-profile-plugin-handles-nonexistent-plugin + (let [profile (th/create-profile* 1) + data {::th/type :remove-profile-plugin + ::rpc/profile-id (:id profile) + :plugin-id (uuid/next)} + out (th/command! data)] + + (t/is (nil? (:error out))) + + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved) + plugins (get-in props [:props :plugins])] + (t/is (or (nil? plugins) + (and (empty? (:ids plugins)) + (empty? (:data plugins)))) + "Plugins should be nil or empty when no plugins exist")))) + +(t/deftest add-profile-plugin-multiple-plugins + (let [profile (th/create-profile* 1) + plugin1 valid-plugin + plugin2 (assoc valid-plugin + :plugin-id plugin-id-2 + :name "Second Plugin") + + data1 {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin plugin1} + _ (th/command! data1) + + data2 {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin plugin2} + _ (th/command! data2)] + + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved) + plugins (get-in props [:props :plugins])] + (t/is (= 2 (count (:ids plugins)))) + (t/is (contains? (set (:ids plugins)) plugin-id-1)) + (t/is (contains? (set (:ids plugins)) plugin-id-2)) + (t/is (= "Test Plugin" (get-in plugins [:data plugin-id-1 :name]))) + (t/is (= "Second Plugin" (get-in plugins [:data plugin-id-2 :name])))))) + +(t/deftest update-profile-props-rejects-plugins + (let [profile (th/create-profile* 1) + data {::th/type :update-profile-props + ::rpc/profile-id (:id profile) + :props {:plugins {:ids ["test"] :data {"test" valid-plugin}}}} + out (th/command! data)] + + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation)) + + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved)] + (t/is (nil? (get-in props [:props :plugins])) + ":plugins must not be writable via update-profile-props")))) diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index ffbb55ca7c..95d9d079c5 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-profile-test (:require @@ -42,7 +42,7 @@ (let [profile (th/create-profile* 1) data {::th/type :login-with-password :email "profile1.test@nodomain.com" - :password "foobar"} + :password "Foobar12!"} out (th/command! data)] #_(th/print-result! out) @@ -56,7 +56,7 @@ (let [profile (th/create-profile* 1) data {::th/type :login-with-password :email "profile1.test@nodomain.com" - :password "123123"} + :password "Test123!"} out (th/command! data)] ;; (th/print-result! out) (let [error (:error out)] @@ -69,7 +69,7 @@ (let [profile (th/create-profile* 1 {:is-active true}) data {::th/type :login-with-password :email "profile1.test@nodomain.com" - :password "123123"} + :password "Test123!"} out (th/command! data)] ;; (th/print-result! out) (t/is (nil? (:error out))) @@ -125,6 +125,17 @@ (t/is (= "en" (:lang result))) (t/is (= "dark" (:theme result)))))) + (t/testing "update profile preserves omitted optional fields" + (let [data {::th/type :update-profile + ::rpc/profile-id (:id profile) + :fullname "Updated Name"} + out (th/command! data)] + + (t/is (nil? (:error out))) + (t/is (= "Updated Name" (get-in out [:result :fullname]))) + (t/is (= "en" (get-in out [:result :lang]))) + (t/is (= "dark" (get-in out [:result :theme]))))) + (t/testing "update photo" (let [data {::th/type :update-profile-photo ::rpc/profile-id (:id profile) @@ -388,6 +399,63 @@ (let [result (th/run-task! :objects-gc {:min-age 0})] (t/is (= 10 (:processed result)))))) +(t/deftest profile-deletion-invalidates-all-sessions + (let [prof (th/create-profile* 1) + + ;; Insert 3 sessions for this profile directly into the database + session-ids (doall + (for [i (range 3)] + (let [sid (uuid/random)] + (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)" + sid (:id prof) (str "user-agent-" i)]) + sid)))] + + ;; Verify sessions exist + (let [count-before (:count (th/db-exec-one! ["SELECT count(*) FROM http_session_v2 WHERE profile_id = ?" (:id prof)]))] + (t/is (= 3 count-before))) + + ;; Request profile to be deleted + (let [params {::th/type :delete-profile + ::rpc/profile-id (:id prof)} + out (th/command! params)] + (t/is (nil? (:error out)))) + + ;; Verify ALL sessions were invalidated (not just one) + (let [count-after (:count (th/db-exec-one! ["SELECT count(*) FROM http_session_v2 WHERE profile_id = ?" (:id prof)]))] + (t/is (= 0 count-after))))) + +(t/deftest profile-deletion-via-gc-cascades + (let [prof (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + team-id (:default-team-id prof) + project-id (:default-project-id prof) + file-id (:id file) + + deleted-at (ct/minus (ct/now) (ct/duration {:days 1}))] + + (th/db-update! :profile + {:deleted-at deleted-at} + {:id (:id prof)}) + + (let [team-before (th/db-get :team {:id team-id} {::db/remove-deleted false})] + (t/is (nil? (:deleted-at team-before)))) + + (let [result (th/run-task! :objects-gc {:min-age 0})] + (t/is (pos? (:processed result)))) + + (let [profile-after (th/db-get :profile {:id (:id prof)} {::db/remove-deleted false})] + (t/is (nil? profile-after))) + + (let [team-after (th/db-get :team {:id team-id} {::db/remove-deleted false})] + (t/is (nil? team-after))) + + (let [project-after (th/db-get :project {:id project-id} {::db/remove-deleted false})] + (t/is (nil? project-after))) + + (let [file-after (th/db-get :file {:id file-id} {::db/remove-deleted false})] + (t/is (nil? file-after))))) (t/deftest email-blacklist-1 (t/is (false? (email.blacklist/enabled? th/*system*))) @@ -403,7 +471,7 @@ (let [data {::th/type :prepare-register-profile :email "user@example.com" :fullname "foobar" - :password "foobar" + :password "Foobar12!" :utm_campaign "utma" :mtm_campaign "mtma"} out (th/command! data) @@ -444,7 +512,7 @@ (let [data {::th/type :prepare-register-profile :email "hello@example.com" :fullname "foobar" - :password "foobar"} + :password "Foobar12!"} out (th/command! data) token (get-in out [:result :token])] (t/is (th/success? out)) @@ -463,7 +531,7 @@ (let [data {::th/type :prepare-register-profile :email "hello@example.com" :fullname "foobar" - :password "foobar"} + :password "Foobar12!"} out (th/command! data) token (get-in out [:result :token])] (t/is (th/success? out)) @@ -498,7 +566,7 @@ (let [data {::th/type :prepare-register-profile :email "hello@example.com" :fullname "foobar" - :password "foobar"} + :password "Foobar12!"} out (th/command! data) token (get-in out [:result :token])] (t/is (th/success? out)) @@ -521,7 +589,7 @@ (let [data {::th/type :prepare-register-profile :email "hello@example.com" :fullname "foobar" - :password "foobar"} + :password "Foobar12!"} out (th/command! data) token (get-in out [:result :token])] (t/is (th/success? out)) @@ -547,7 +615,7 @@ (let [data {::th/type :prepare-register-profile :email "hello@example.com" :fullname "foobar" - :password "foobar"} + :password "Foobar12!"} out (th/command! data) token (get-in out [:result :token])] (t/is (th/success? out)) @@ -576,7 +644,7 @@ (let [data {::th/type :prepare-register-profile :email "hello@example.com" :fullname "foobar" - :password "foobar"} + :password "Foobar12!"} out (th/command! data) token (get-in out [:result :token])] (t/is (th/success? out)) @@ -614,7 +682,7 @@ :invitation-token itoken :fullname "foobar" :email "user@example.com" - :password "foobar"} + :password "Foobar12!"} {prep-result :result prep-error :error} (th/command! prep-data)] (t/is (nil? prep-error)) @@ -659,7 +727,7 @@ :invitation-token itoken :fullname "foobar" :email "user@example.com" - :password "foobar"} + :password "Foobar12!"} {prep-result :result prep-error :error} (th/command! prep-data)] (t/is (nil? prep-error)) @@ -692,7 +760,7 @@ :invitation-token itoken :email "user@example.com" :fullname "foobar" - :password "foobar"} + :password "Foobar12!"} out (th/command! data)] (t/is (not (th/success? out))) @@ -712,7 +780,7 @@ :invitation-token itoken :fullname "foobar" :email "user@example.com" - :password "foobar"} + :password "Foobar12!"} out (th/command! data)] (t/is (not (th/success? out))) @@ -733,7 +801,7 @@ :invitation-token itoken :email "user@example.com" :fullname "foobar" - :password "foobar"} + :password "Foobar12!"} out (th/command! data)] (t/is (not (th/success? out))) @@ -754,7 +822,7 @@ :invitation-token itoken :fullname "foobar" :email "user@example.com" - :password "foobar"} + :password "Foobar12!"} out (th/command! data)] (t/is (not (th/success? out))) @@ -767,7 +835,7 @@ (let [data {::th/type :prepare-register-profile :fullname "foobar" :email "user@example.com" - :password "foobar"} + :password "Foobar12!"} out (th/command! data)] (t/is (not (th/success? out))) @@ -780,7 +848,7 @@ data {::th/type :prepare-register-profile :fullname "foobar" :email (:email profile) - :password "foobar"} + :password "Foobar12!"} out (th/command! data)] ;; (th/print-result! out) (t/is (th/success? out)) @@ -793,7 +861,7 @@ data {::th/type :prepare-register-profile :fullname "foobar" :email "user@example.com" - :password "foobar"}] + :password "Foobar12!"}] (th/create-global-complaint-for pool {:type :bounce :email "user@example.com"}) @@ -808,7 +876,7 @@ data {::th/type :prepare-register-profile :fullname "foobar" :email "user@example.com" - :password "foobar"}] + :password "Foobar12!"}] (th/create-global-complaint-for pool {:type :complaint :email "user@example.com"}) @@ -1131,8 +1199,8 @@ (let [profile (th/create-profile* 1) data {::th/type :update-profile-password ::rpc/profile-id (:id profile) - :old-password "123123" - :password "foobarfoobar"} + :old-password "Test123!" + :password "Foobar12!"} out (th/command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out))))) @@ -1143,7 +1211,7 @@ data {::th/type :update-profile-password ::rpc/profile-id (:id profile) :old-password "badpassword" - :password "foobarfoobar"} + :password "Foobar12!"} {:keys [result error] :as out} (th/command! data)] (t/is (th/ex-info? error)) (t/is (th/ex-of-type? error :validation)) @@ -1154,7 +1222,7 @@ (let [profile (th/create-profile* 1) data {::th/type :update-profile-password ::rpc/profile-id (:id profile) - :old-password "123123" + :old-password "Test123!" :password "profile1.test@nodomain.com"} {:keys [result error] :as out} (th/command! data)] (t/is (th/ex-info? error)) @@ -1271,3 +1339,49 @@ (t/is (th/ex-info? (:error out))) (t/is (th/ex-of-type? (:error out) :validation)) (t/is (th/ex-of-code? (:error out) :params-validation)))) + + +(t/deftest prepare-register-profile-password-too-short + (let [data {::th/type :prepare-register-profile + :email "user@example.com" + :fullname "foobar" + :password "123"} + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :weak-password)))) + + +(t/deftest prepare-register-profile-weak-password + (let [data {::th/type :prepare-register-profile + :email "user@example.com" + :fullname "foobar" + :password "password123"} + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :weak-password)))) + + +(t/deftest update-profile-password-too-short + (let [profile (th/create-profile* 1) + data {::th/type :update-profile-password + ::rpc/profile-id (:id profile) + :old-password "Test123!" + :password "123"} + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :weak-password)))) + + +(t/deftest update-profile-password-weak-password + (let [profile (th/create-profile* 1) + data {::th/type :update-profile-password + ::rpc/profile-id (:id profile) + :old-password "Test123!" + :password "qwerty"} + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :weak-password)))) diff --git a/backend/test/backend_tests/rpc_project_test.clj b/backend/test/backend_tests/rpc_project_test.clj index 96376a42b1..f1443ce931 100644 --- a/backend/test/backend_tests/rpc_project_test.clj +++ b/backend/test/backend_tests/rpc_project_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-project-test (:require @@ -241,3 +241,24 @@ error-data (ex-data error)] (t/is (th/ex-info? error)) (t/is (= (:type error-data) :not-found)))))) + +(t/deftest get-project-nonexistent + (let [prof (th/create-profile* 1 {:is-active true}) + out (th/command! {::th/type :get-project + ::rpc/profile-id (:id prof) + :id (uuid/random)}) + err (:error out)] + (t/is (th/ex-info? err)) + (t/is (th/ex-of-type? err :not-found)))) + +(t/deftest get-project-no-permission + (let [owner (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + proj (th/create-project* 1 {:profile-id (:id owner) + :team-id (:default-team-id owner)}) + out (th/command! {::th/type :get-project + ::rpc/profile-id (:id other) + :id (:id proj)}) + err (:error out)] + (t/is (th/ex-info? err)) + (t/is (th/ex-of-type? err :not-found)))) diff --git a/backend/test/backend_tests/rpc_quotes_test.clj b/backend/test/backend_tests/rpc_quotes_test.clj index 94db804e17..c691ea678b 100644 --- a/backend/test/backend_tests/rpc_quotes_test.clj +++ b/backend/test/backend_tests/rpc_quotes_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-quotes-test (:require @@ -338,3 +338,137 @@ (check-ok! 4) (check-ko! 5)))) + +(t/deftest media-storage-bytes-per-team-quote + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-media-storage-bytes-per-team 1000})}] + + (let [profile-1 (th/create-profile* 1) + profile-2 (th/create-profile* 2) + team-id (:default-team-id profile-1) + data {::quotes/id ::quotes/media-storage-bytes-per-team + ::quotes/profile-id (:id profile-1) + ::quotes/team-id team-id + ::quotes/incr 500} + + check-ok! (fn [msg] + (quotes/check! th/*system* data) + (t/is (true? true) msg)) + check-ko! (fn [msg] + (try + (quotes/check! th/*system* data) + (t/is false (str msg " — expected exception but none thrown")) + (catch Exception e + (let [ed (ex-data e)] + (t/is (= :restriction (:type ed))) + (t/is (= :max-quote-reached (:code ed))) + (t/is (= "media-storage-bytes-per-team" (:target ed)))))))] + + ;; Under default limit (1000) with incr=500 and no existing storage — ok + (check-ok! "first check under limit") + + ;; Insert a quote row for another profile on the same team — does not help + (th/db-insert! :usage-quote + {:profile-id (:id profile-2) + :target "media-storage-bytes-per-team" + :quote 100}) + + ;; Insert a team+profile quote that is still too low + (th/db-insert! :usage-quote + {:team-id team-id + :profile-id (:id profile-2) + :target "media-storage-bytes-per-team" + :quote 200}) + + ;; Insert a team-level quote (no profile) that is still too low + (th/db-insert! :usage-quote + {:team-id team-id + :target "media-storage-bytes-per-team" + :quote 400}) + + ;; total=0, incr=500, best quote=400 → 0+500 > 400 → blocked + (check-ko! "blocked by team-level quote") + + ;; Insert a team+profile quote that allows it + (th/db-insert! :usage-quote + {:team-id team-id + :profile-id (:id profile-1) + :target "media-storage-bytes-per-team" + :quote 1000}) + + ;; total=0, incr=500, best quote=1000 → 0+500 <= 1000 → ok + (check-ok! "allowed by team+profile quote")))) + +(t/deftest media-storage-bytes-quote-deduped + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-media-storage-bytes-per-team 1100})}] + + (let [prof (th/create-profile* 1) + team-id (:default-team-id prof) + proj (th/create-project* 1 {:profile-id (:id prof) + :team-id team-id}) + file1 (th/create-file* 1 {:profile-id (:id prof) + :project-id (:id proj) + :is-shared false}) + file2 (th/create-file* 2 {:profile-id (:id prof) + :project-id (:id proj) + :is-shared false}) + + ;; One physical storage object of 500 bytes + so-id (uuid/random) + _ (th/db-insert! :storage-object {:id so-id + :size 500 + :backend "test"}) + + ;; Two file_media_object rows pointing at the SAME storage object + ;; (simulates the deduplication path: same content uploaded twice) + _ (th/create-file-media-object* + {:file-id (:id file1) :media-id so-id + :name "icon" :mtype "image/svg+xml"}) + _ (th/create-file-media-object* + {:file-id (:id file2) :media-id so-id + :name "icon" :mtype "image/svg+xml"}) + + data {::quotes/id ::quotes/media-storage-bytes-per-team + ::quotes/profile-id (:id prof) + ::quotes/team-id team-id + ::quotes/incr 200}] + + ;; Physical size is 500. With UNION (correct), total=500, 500+200=700 ≤ 1100 → ok. + ;; With UNION ALL (buggy), total=1000, 1000+200=1200 > 1100 → rejected. + (quotes/check! th/*system* data) + (t/is (true? true) "deduped storage counted once, under quota")))) + +(t/deftest media-upload-enforces-storage-quote + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-media-storage-bytes-per-team 100})}] + + (let [prof (th/create-profile* 1) + proj (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:id proj) + :is-shared false}) + mfile {:filename "sample.jpg" + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg" + :size 312043} + + params {::th/type :upload-file-media-object + ::rpc/profile-id (:id prof) + :file-id (:id file) + :is-local true + :name "testfile" + :content mfile} + + out (th/command! params)] + + ;; 312043 bytes > 100 byte limit → should be rejected + (t/is (not (th/success? out))) + (let [error (:error out)] + (t/is (= :restriction (th/ex-type error))) + (t/is (= :max-quote-reached (th/ex-code error))) + (t/is (= "media-storage-bytes-per-team" (:target (ex-data error)))))))) diff --git a/backend/test/backend_tests/rpc_rlimit_test.clj b/backend/test/backend_tests/rpc_rlimit_test.clj new file mode 100644 index 0000000000..a10c6363af --- /dev/null +++ b/backend/test/backend_tests/rpc_rlimit_test.clj @@ -0,0 +1,28 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.rpc-rlimit-test + (:require + [app.common.time :as ct] + [app.redis :as rds] + [app.rpc.rlimit :as rlimit] + [clojure.test :as t])) + +(t/deftest bucket-reset-supports-fractional-milliseconds + (let [now (ct/inst 0) + limit {::rlimit/name :test + ::rlimit/strategy :bucket + ::rlimit/key "test" + ::rlimit/method "main.test" + ::rlimit/capacity 5 + ::rlimit/rate 3 + ::rlimit/interval (ct/duration 1000) + ::rlimit/params [1 3 5] + ::rlimit/opts "5/3/1s"}] + (with-redefs [rds/eval (fn [_ _] [true 4])] + (let [result (rlimit/process-limit nil "profile" now limit)] + (t/is (= (ct/inst 334) + (:app.rpc.rlimit.result/reset result))))))) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 7c2b5d0552..0fcb427ad2 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-team-test (:require @@ -154,10 +154,14 @@ (get-in % [:props :member-email]))) events))] (doseq [event [create-organization update-organization]] + (t/is (= (str (:id owner)) + (get-in event [:props :user-who-send-invitation]))) (t/is (true? (get-in event [:props :team-belongs-to-organization]))) (t/is (true? (get-in event [:props :adds-invitee-to-organization]))) (t/is (true? (get-in event [:props :invitee-already-organization-member])))) + (t/is (= (str (:id owner)) + (get-in create-plain [:props :user-who-send-invitation]))) (t/is (false? (get-in create-plain [:props :team-belongs-to-organization]))) (t/is (false? (get-in create-plain [:props :adds-invitee-to-organization]))) (t/is (false? (get-in create-plain [:props :invitee-already-organization-member]))))))) @@ -357,6 +361,28 @@ (t/is (= (:id profile2) (:member-id claims)))))))) +(t/deftest get-team-invitation-token-requires-edition-permissions + (let [profile1 (th/create-profile* 1 {:is-active true}) + profile2 (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id profile1)}) + pool (:app.db/pool th/*system*)] + (th/create-team-role* {:team-id (:id team) + :profile-id (:id profile2) + :role :viewer}) + (db/insert! pool :team-invitation + {:team-id (:id team) + :email-to "victim@example.com" + :role "editor" + :valid-until (ct/in-future "48h")}) + (let [data {::th/type :get-team-invitation-token + ::rpc/profile-id (:id profile2) + :team-id (:id team) + :email "victim@example.com"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (= :not-found (-> out :error ex-data :type)))))) + + (t/deftest accept-invitation-tokens (let [profile1 (th/create-profile* 1 {:is-active true}) profile2 (th/create-profile* 2 {:is-active true}) @@ -499,6 +525,9 @@ (let [event (organization-event)] (t/is (= organization-id (get-in event [:props :organization-id]))) + (t/is (= (:id invitee) (get-in event [:props :user-id]))) + (t/is (= (:id inviter) + (get-in event [:props :user-who-send-invitation]))) (t/is (not (contains? (:props event) :organization-member-add-source))) (t/is (not (contains? (:props event) :belongs-to-team-on-add))) (t/is (not (contains? (:props event) :organization-member-count-before))) @@ -508,6 +537,10 @@ (:origin @frontend-event))) (t/is (= organization-id (get-in @frontend-event [:props :organization-id]))) + (t/is (= (:id invitee) + (get-in @frontend-event [:props :user-id]))) + (t/is (= (:id inviter) + (get-in @frontend-event [:props :user-who-send-invitation]))) (t/is (= "direct-organization-invitation" (get-in @frontend-event [:props :organization-member-add-source]))) (t/is (false? (get-in @frontend-event [:props :belongs-to-team-on-add]))) @@ -548,6 +581,9 @@ (t/is (some #(= "accept-team-invitation-from" (:name %)) events)) (t/is (= (:id team) (get-in event [:props :team-id]))) (t/is (= organization-id (get-in event [:props :organization-id]))) + (t/is (= (:id invitee) (get-in event [:props :user-id]))) + (t/is (= (:id inviter) + (get-in event [:props :user-who-send-invitation]))) (t/is (not (contains? (:props event) :organization-member-add-source))) (t/is (not (contains? (:props event) :belongs-to-team-on-add))) (t/is (not (contains? (:props event) :organization-member-count-before))) @@ -556,6 +592,10 @@ (t/is (= (:id team) (get-in @frontend-event [:props :team-id]))) (t/is (= organization-id (get-in @frontend-event [:props :organization-id]))) + (t/is (= (:id invitee) + (get-in @frontend-event [:props :user-id]))) + (t/is (= (:id inviter) + (get-in @frontend-event [:props :user-who-send-invitation]))) (t/is (= "team-invitation" (get-in @frontend-event [:props :organization-member-add-source]))) (t/is (true? (get-in @frontend-event [:props :belongs-to-team-on-add]))) @@ -719,6 +759,67 @@ (t/is (not= (:default-team-id profile1) (:id item1)))))) +(t/deftest get-teams-fetches-organizations-in-one-batch + (let [profile (th/create-profile* 1 {:is-active true}) + organization-team (th/create-team* 1 {:profile-id (:id profile)}) + plain-team (th/create-team* 2 {:profile-id (:id profile)}) + expired-team (th/create-team* 3 {:profile-id (:id profile)}) + organization-id (uuid/random) + calls (atom []) + organization {:id organization-id + :name "Acme" + :slug "acme" + :owner-id (:id profile) + :avatar-bg-url "https://example.com/avatar.svg"} + nitrate-call (fn [_cfg method params] + (swap! calls conj [method params]) + [{:id (:id organization-team) + :is-your-penpot false + :organization organization} + {:id (:id expired-team) + :is-your-penpot false + :organization (assoc organization :expired-license true)}]) + params {::th/type :get-teams + ::rpc/profile-id (:id profile)}] + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call nitrate-call] + (let [out (th/command! params) + teams (:result out)] + (t/is (th/success? out)) + (t/is (= 1 (count @calls))) + (t/is (= :get-teams-organizations (ffirst @calls))) + (t/is (= #{(:default-team-id profile) + (:id organization-team) + (:id plain-team) + (:id expired-team)} + (-> @calls first second :team-ids set))) + (t/is (= #{(:default-team-id profile) + (:id organization-team) + (:id plain-team)} + (into #{} (map :id) teams))) + (t/is (= organization + (->> teams + (filter #(= (:id organization-team) (:id %))) + first + :organization))))))) + + +(t/deftest get-teams-rejects-invalid-organization-batch-response + (let [profile (th/create-profile* 1 {:is-active true}) + calls (atom []) + params {::th/type :get-teams + ::rpc/profile-id (:id profile)}] + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call (fn [_cfg method call-params] + (swap! calls conj [method call-params]) + nil)] + (let [out (th/command! params)] + (t/is (not (th/success? out))) + (t/is (= :nitrate-unavailable (th/ex-type (:error out)))) + (t/is (= 1 (count @calls))) + (t/is (= :get-teams-organizations (ffirst @calls))))))) + + (t/deftest team-deletion-1 (let [profile1 (th/create-profile* 1 {:is-active true}) team (th/create-team* 1 {:profile-id (:id profile1)}) @@ -1015,6 +1116,46 @@ out (th/command! data)] (t/is (th/success? out))))) +(t/deftest create-team-invitations-email-cooldown + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [profile1 (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id profile1)}) + + data {::th/type :create-team-invitations + ::rpc/profile-id (:id profile1) + :team-id (:id team) + :role :editor + :emails ["cooldown-test@example.com"]}] + + ;; First invitation sends email + (let [out (th/command! data)] + (t/is (th/success? out)) + (t/is (= 1 (:call-count @mock)))) + + ;; Resending immediately should NOT send email (cooldown active) + (th/reset-mock! mock) + (let [out (th/command! data)] + (t/is (th/success? out)) + (t/is (= 0 (:call-count @mock)))) + + ;; Resending to a different email should send email + (th/reset-mock! mock) + (let [data (assoc data :emails ["different@example.com"]) + out (th/command! data)] + (t/is (th/success? out)) + (t/is (= 1 (:call-count @mock)))) + + ;; After cooldown expires, resending should send email + (th/reset-mock! mock) + (th/db-update! :team-invitation + {:updated-at (ct/in-past "10m")} + {:team-id (:id team) + :email-to "cooldown-test@example.com"}) + (let [data (assoc data :emails ["cooldown-test@example.com"]) + out (th/command! data)] + (t/is (th/success? out)) + (t/is (= 1 (:call-count @mock))))))) + (t/deftest update-team-with-invalid-name (let [profile (th/create-profile* 1 {:is-active true}) team (th/create-team* 1 {:profile-id (:id profile)})] @@ -1056,3 +1197,232 @@ :name "My Valid Team"} out (th/command! data)] (t/is (th/success? out))))) + +(t/deftest create-team-in-organization-regression + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (let [owner (th/create-profile* 401 {:is-active true}) + non-member (th/create-profile* 402 {:is-active true}) + organization-id (uuid/random) + params {::th/type :create-team + ::rpc/profile-id (:id owner) + :name "Test Team" + :organization-id organization-id} + + nitrate-call-fn + (fn [_cfg method p] + (case method + :get-organization-membership + (if (= (:profile-id p) (:id non-member)) + {:organization-id organization-id :is-member false} + {:organization-id organization-id :is-member true}) + + :get-organization-permissions + {:owner-id (:id owner) + :permissions {:create-teams "any"}} + + :set-team-organization + (let [team-id (:team-id p)] + {:id team-id + :name "Test Team" + :organization-id organization-id + :default-project-id (uuid/random)}) + + nil))] + + ;; Non-member should be denied with :user-doesnt-belong-organization + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call nitrate-call-fn] + (let [out (th/command! (assoc params ::rpc/profile-id (:id non-member)))] + (t/is (not (th/success? out))) + (let [edata (-> out :error ex-data)] + (t/is (= :validation (:type edata))) + (t/is (= :user-doesnt-belong-organization (:code edata)))))) + + ;; Authorized member should succeed + (th/reset-mock! audit-mock) + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call nitrate-call-fn] + (let [out (th/command! params)] + (t/is (th/success? out)) + (let [team (:result out)] + (t/is (uuid? (:id team))) + (t/is (= "Test Team" (:name team))))))))) + +;; --- T7-F-01: Role ceiling in team invitations --- + +(t/deftest admin-cannot-create-invitation-with-owner-role + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [owner (th/create-profile* 1 {:is-active true}) + admin (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + ;; Add admin as team member with :admin role + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin) + :role :admin}) + + ;; Admin tries to create invitation with :owner role (emails+role format) + ;; This should FAIL with :cant-promote-to-owner + (let [data {::th/type :create-team-invitations + ::rpc/profile-id (:id admin) + :team-id (:id team) + :role :owner + :emails ["invitee@example.com"]} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :cant-promote-to-owner)) + (t/is (= 0 (:call-count @mock))))))) + +(t/deftest admin-cannot-create-invitation-with-owner-role-invitations-format + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [owner (th/create-profile* 1 {:is-active true}) + admin (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + ;; Add admin as team member with :admin role + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin) + :role :admin}) + + ;; Admin tries to create invitation with :owner role (invitations format) + ;; This should FAIL with :cant-promote-to-owner + (let [data {::th/type :create-team-invitations + ::rpc/profile-id (:id admin) + :team-id (:id team) + :invitations [{:email "invitee@example.com" :role :owner}]} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :cant-promote-to-owner)) + (t/is (= 0 (:call-count @mock))))))) + +(t/deftest admin-cannot-update-invitation-role-to-owner + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [owner (th/create-profile* 1 {:is-active true}) + admin (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + ;; Add admin as team member with :admin role + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin) + :role :admin}) + + ;; Owner creates an invitation with :editor role + (let [data {::th/type :create-team-invitations + ::rpc/profile-id (:id owner) + :team-id (:id team) + :role :editor + :emails ["invitee@example.com"]} + out (th/command! data)] + (t/is (th/success? out))) + + (th/reset-mock! mock) + + ;; Admin tries to update invitation role to :owner + ;; This should FAIL with :cant-promote-to-owner + (let [data {::th/type :update-team-invitation-role + ::rpc/profile-id (:id admin) + :team-id (:id team) + :email "invitee@example.com" + :role :owner} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :cant-promote-to-owner)))))) + +(t/deftest owner-can-create-invitation-with-owner-role + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + ;; Owner creates invitation with :owner role + ;; This should SUCCEED (owner has full privileges) + (let [data {::th/type :create-team-invitations + ::rpc/profile-id (:id owner) + :team-id (:id team) + :role :owner + :emails ["invitee@example.com"]} + out (th/command! data)] + (t/is (th/success? out)) + (t/is (= 1 (:call-count @mock))))))) + +(t/deftest admin-cannot-remove-team-owner + (let [owner (th/create-profile* 1 {:is-active true}) + admin (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin) + :role :admin}) + + (let [out (th/command! {::th/type :delete-team-member + ::rpc/profile-id (:id admin) + :team-id (:id team) + :member-id (:id owner)})] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :cant-remove-owner))))) + +(t/deftest owner-can-remove-another-owner + (let [owner1 (th/create-profile* 1 {:is-active true}) + owner2 (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner1)})] + + (th/create-team-role* {:team-id (:id team) + :profile-id (:id owner2) + :role :owner}) + + (let [out (th/command! {::th/type :delete-team-member + ::rpc/profile-id (:id owner1) + :team-id (:id team) + :member-id (:id owner2)})] + (t/is (th/success? out))))) + +(t/deftest owner-can-remove-admin + (let [owner (th/create-profile* 1 {:is-active true}) + admin (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin) + :role :admin}) + + (let [out (th/command! {::th/type :delete-team-member + ::rpc/profile-id (:id owner) + :team-id (:id team) + :member-id (:id admin)})] + (t/is (th/success? out))))) + +(t/deftest admin-can-remove-admin + (let [owner (th/create-profile* 1 {:is-active true}) + admin1 (th/create-profile* 2 {:is-active true}) + admin2 (th/create-profile* 3 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin1) + :role :admin}) + + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin2) + :role :admin}) + + (let [out (th/command! {::th/type :delete-team-member + ::rpc/profile-id (:id admin1) + :team-id (:id team) + :member-id (:id admin2)})] + (t/is (th/success? out))))) + +(t/deftest delete-nonexistent-member-returns-not-found + (let [owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)}) + fake-id (uuid/next)] + + (let [out (th/command! {::th/type :delete-team-member + ::rpc/profile-id (:id owner) + :team-id (:id team) + :member-id fake-id})] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :not-found)) + (t/is (th/ex-of-code? (:error out) :member-does-not-exist))))) diff --git a/backend/test/backend_tests/rpc_viewer_test.clj b/backend/test/backend_tests/rpc_viewer_test.clj index 14040aeacb..cbf9e4493a 100644 --- a/backend/test/backend_tests/rpc_viewer_test.clj +++ b/backend/test/backend_tests/rpc_viewer_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-viewer-test (:require @@ -128,3 +128,80 @@ (let [result (:result out)] (t/is (contains? result :file)) (t/is (contains? result :project))))))) + +(t/deftest share-link-token-disclosure + (let [owner (th/create-profile* 1 {:is-active true}) + proj-id (:default-project-id owner) + + file (th/create-file* 1 {:profile-id (:id owner) + :project-id proj-id + :is-shared false}) + + page-a (get-in file [:data :pages 0]) + page-b (uuid/random) + + ;; Add a second page to the file + _ (th/command! {::th/type :update-file + ::rpc/profile-id (:id owner) + :id (:id file) + :session-id (uuid/random) + :revn 0 + :vern 0 + :changes [{:type :add-page + :id page-b + :page {:id page-b + :name "Page B" + :options {} + :objects {}}}]}) + + ;; Create Link A: restrictive (no pages, team-only comments/inspect) + link-a (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{} + :who-comment "team" + :who-inspect "team"}) + link-a-id (get-in link-a [:result :id]) + + ;; Create Link B: permissive (all pages, all can comment/inspect) + link-b (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{page-a page-b} + :who-comment "all" + :who-inspect "all"}) + link-b-id (get-in link-b [:result :id])] + + (t/testing "restrictive share-link holder cannot see other share-link tokens" + (let [out (th/command! {::th/type :get-view-only-bundle + :share-id link-a-id + :file-id (:id file)}) + err (:error out) + result (:result out) + share-links (:share-links result)] + + ;; Should not error + (t/is (nil? err)) + + ;; Should only see the share-link used for authentication + (t/is (= 1 (count share-links))) + (t/is (= link-a-id (:id (first share-links)))) + + ;; Should NOT see Link B's token + (t/is (not (some #(= link-b-id (:id %)) share-links))))) + + (t/testing "team member still sees all share-links" + (let [out (th/command! {::th/type :get-view-only-bundle + ::rpc/profile-id (:id owner) + :file-id (:id file)}) + err (:error out) + result (:result out) + share-links (:share-links result)] + + ;; Should not error + (t/is (nil? err)) + + ;; Team member should see both share-links + (t/is (= 2 (count share-links))) + (t/is (some #(= link-a-id (:id %)) share-links)) + (t/is (some #(= link-b-id (:id %)) share-links)))))) diff --git a/backend/test/backend_tests/rpc_webhooks_test.clj b/backend/test/backend_tests/rpc_webhooks_test.clj index 3b39c8b52d..5b15d46111 100644 --- a/backend/test/backend_tests/rpc_webhooks_test.clj +++ b/backend/test/backend_tests/rpc_webhooks_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-webhooks-test (:require @@ -155,8 +155,7 @@ :return {:status 200}}] (let [owner (th/create-profile* 1 {:is-active true}) viewer (th/create-profile* 2 {:is-active true}) - team (th/create-team* 1 {:profile-id (:id owner)}) - whook (volatile! nil)] + team (th/create-team* 1 {:profile-id (:id owner)})] (th/create-team-role* {:team-id (:id team) :profile-id (:id viewer) :role :viewer}) @@ -164,52 +163,15 @@ (let [roles (th/db-query :team-profile-rel {:team-id (:id team)})] (t/is (= 2 (count roles)))) - (t/testing "viewer creates a webhook" + (t/testing "viewer cannot create a webhook (requires editor role)" (let [viewers-webhook (create-webhook-params (:id viewer) (:id team)) out (th/command! viewers-webhook)] - (t/is (nil? (:error out))) - (t/is (= 1 (:call-count @http-mock))) - - (let [result (:result out)] - (check-webhook-format result) - (t/is (= (:uri viewers-webhook) (:uri result))) - (t/is (= (:team-id viewers-webhook) (:team-id result))) - (t/is (= (::rpc/profile-id viewers-webhook) (:profile-id result))) - (t/is (= (:mtype viewers-webhook) (:mtype result))) - (vreset! whook result)))) - - (th/reset-mock! http-mock) - - (t/testing "viewer updates it's own webhook (success)" - (let [params {::th/type :update-webhook - ::rpc/profile-id (:id viewer) - :id (:id @whook) - :uri (:uri @whook) - :mtype "application/transit+json" - :is-active false} - out (th/command! params) - result (:result out)] - - (t/is (nil? (:error out))) (t/is (= 0 (:call-count @http-mock))) - (check-webhook-format result) - (t/is (= (:is-active params) (:is-active result))) - (t/is (= (:team-id @whook) (:team-id result))) - (t/is (= (:mtype params) (:mtype result))) - (vreset! whook result))) - - (th/reset-mock! http-mock) - - (t/testing "viewer deletes it's own webhook (success)" - (let [params {::th/type :delete-webhook - ::rpc/profile-id (:id viewer) - :id (:id @whook)} - out (th/command! params)] - (t/is (= 0 (:call-count @http-mock))) - (t/is (nil? (:error out))) - (t/is (nil? (:result out))) - (let [rows (th/db-exec! ["select * from webhook"])] - (t/is (= 0 (count rows)))))) + (let [error (:error out) + error-data (ex-data error)] + (t/is (th/ex-info? error)) + (t/is (= (:type error-data) :not-found)) + (t/is (= (:code error-data) :object-not-found))))) (th/reset-mock! http-mock)))) @@ -268,6 +230,26 @@ (t/is (= (:type error-data) :not-found)) (t/is (= (:code error-data) :object-not-found))))))) +(t/deftest webhooks-viewer-cannot-create + (with-mocks [http-mock {:target 'app.http.client/req + :return {:status 200}}] + (let [owner (th/create-profile* 1 {:is-active true}) + viewer (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + (th/create-team-role* {:team-id (:id team) + :profile-id (:id viewer) + :role :viewer}) + + (t/testing "viewer cannot create a webhook on the team" + (let [params (create-webhook-params (:id viewer) (:id team)) + out (th/command! params)] + (t/is (= 0 (:call-count @http-mock))) + (let [error (:error out) + error-data (ex-data error)] + (t/is (th/ex-info? error)) + (t/is (= (:type error-data) :not-found)) + (t/is (= (:code error-data) :object-not-found)))))))) + (t/deftest webhooks-quotes (with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}] @@ -304,3 +286,91 @@ (t/is (th/ex-info? error)) (t/is (= (:type error-data) :restriction)) (t/is (= (:code error-data) :webhooks-quote-reached)))))) + +(t/deftest removed-user-cannot-edit-webhook + (with-mocks [http-mock {:target 'app.http.client/req + :return {:status 200}}] + + (let [owner (th/create-profile* 1 {:is-active true}) + editor (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + (th/create-team-role* {:team-id (:id team) + :profile-id (:id editor) + :role :editor}) + + (let [params {::th/type :create-webhook + ::rpc/profile-id (:id editor) + :team-id (:id team) + :uri (u/uri "http://example.com") + :mtype "application/json"} + out (th/command! params)] + + (t/is (nil? (:error out))) + (let [whook (:result out)] + + (th/reset-mock! http-mock) + + (t/testing "owner can edit editor's webhook (team owns it)" + (let [params {::th/type :update-webhook + ::rpc/profile-id (:id owner) + :id (:id whook) + :uri (u/uri "http://example.com/updated") + :mtype "application/transit+json" + :is-active true} + out (th/command! params)] + (t/is (nil? (:error out))) + (t/is (= 1 (:call-count @http-mock))))) + + (th/reset-mock! http-mock) + + (t/testing "remove editor from team" + (let [params {::th/type :delete-team-member + ::rpc/profile-id (:id owner) + :team-id (:id team) + :member-id (:id editor)} + out (th/command! params)] + (t/is (nil? (:error out))))) + + (th/reset-mock! http-mock) + + (t/testing "removed editor cannot update webhook" + (let [params {::th/type :update-webhook + ::rpc/profile-id (:id editor) + :id (:id whook) + :uri (u/uri "http://example.com/evil") + :mtype "application/transit+json" + :is-active true} + out (th/command! params)] + (t/is (= 0 (:call-count @http-mock))) + (let [error (:error out) + error-data (ex-data error)] + (t/is (th/ex-info? error)) + (t/is (= (:type error-data) :not-found)) + (t/is (= (:code error-data) :object-not-found))))) + + (th/reset-mock! http-mock) + + (t/testing "removed editor cannot delete webhook" + (let [params {::th/type :delete-webhook + ::rpc/profile-id (:id editor) + :id (:id whook)} + out (th/command! params)] + (t/is (= 0 (:call-count @http-mock))) + (let [error (:error out) + error-data (ex-data error)] + (t/is (th/ex-info? error)) + (t/is (= (:type error-data) :not-found)) + (t/is (= (:code error-data) :object-not-found))))) + + (th/reset-mock! http-mock) + + (t/testing "owner can still delete editor's webhook" + (let [params {::th/type :delete-webhook + ::rpc/profile-id (:id owner) + :id (:id whook)} + out (th/command! params)] + (t/is (nil? (:error out))) + (t/is (nil? (:result out))) + (let [rows (th/db-exec! ["select * from webhook"])] + (t/is (= 0 (count rows))))))))))) diff --git a/backend/test/backend_tests/shell_test.clj b/backend/test/backend_tests/shell_test.clj index c9d1932c44..df8a4336b1 100644 --- a/backend/test/backend_tests/shell_test.clj +++ b/backend/test/backend_tests/shell_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.shell-test (:require diff --git a/backend/test/backend_tests/storage_test.clj b/backend/test/backend_tests/storage_test.clj index 348a978fc2..cc34773e67 100644 --- a/backend/test/backend_tests/storage_test.clj +++ b/backend/test/backend_tests/storage_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.storage-test (:require @@ -12,12 +12,23 @@ [app.db :as db] [app.rpc :as-alias rpc] [app.storage :as sto] + [app.storage.fs :as-alias sto.fs] + [app.storage.impl :as impl] + [app.storage.s3 :as-alias sto.s3] [backend-tests.helpers :as th] [clojure.test :as t] [cuerdas.core :as str] [datoteka.fs :as fs] [datoteka.io :as io] - [mockery.core :refer [with-mocks]])) + [mockery.core :refer [with-mocks]] + [promesa.core :as p]) + (:import + (software.amazon.awssdk.services.s3 + S3AsyncClient) + (software.amazon.awssdk.services.s3.model + NoSuchKeyException) + (software.amazon.awssdk.services.s3.presigner + S3Presigner))) (t/use-fixtures :once th/state-init) (t/use-fixtures :each (th/serial @@ -199,6 +210,25 @@ (let [res (th/db-exec-one! ["select count(*) from storage_object where deleted_at is not null"])] (t/is (= 0 (:count res))))))) +(defn- upload-font-chunked! + "Splits `font-bytes` into a single chunk, creates an upload session, + uploads the chunk, and returns the session-id UUID." + [prof ^bytes font-bytes mtype] + (let [tmp (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-font-chunk-") + _ (io/write* tmp font-bytes) + mfile {:filename "chunk" :path tmp :mtype mtype :size (alength font-bytes)} + session-id (-> (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 1}) + :result :session-id) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] + (assert (nil? (:error out))) + session-id)) + (t/deftest touched-gc-task-2 (let [storage (-> (:app.storage/storage th/*system*) (configure-storage-backend)) @@ -229,6 +259,8 @@ :name "testfile" :content mfile} + session-id (upload-font-chunked! prof ttfdata "font/ttf") + params2 {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id @@ -236,7 +268,7 @@ :font-family "somefont" :font-weight 400 :font-style "normal" - :data {"font/ttf" ttfdata}} + :uploads {"font/ttf" session-id}} out1 (th/command! params1) out2 (th/command! params2)] @@ -250,7 +282,7 @@ (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! :storage-gc-touched {}))] (t/is (= 5 (:freeze res))) - (t/is (= 0 (:delete res))) + (t/is (= 1 (:delete res))) (let [result-1 (:result out1) result-2 (:result out2)] @@ -271,7 +303,7 @@ (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! :storage-gc-touched {}))] (t/is (= 2 (:freeze res))) - (t/is (= 3 (:delete res)))) + (t/is (= 4 (:delete res)))) ;; now check that there are no touched objects (let [res (th/db-exec-one! ["select count(*) from storage_object where touched_at is not null"])] @@ -279,7 +311,7 @@ ;; now check that all objects are marked to be deleted (let [res (th/db-exec-one! ["select count(*) from storage_object where deleted_at is not null"])] - (t/is (= 3 (:count res)))))))) + (t/is (= 4 (:count res)))))))) (t/deftest touched-gc-task-3 (let [storage (-> (:app.storage/storage th/*system*) @@ -347,27 +379,498 @@ now (ct/now) object1 (sto/put-object! storage {::sto/content content1 - ::sto/touched-at (ct/plus now {:minutes 1}) + ::sto/touched-at (ct/plus now {:hours 1}) :bucket "tempfile" :content-type "text/plain"})] - + ;; not eligible while the touched-at is in the future (binding [ct/*clock* (ct/fixed-clock now)] (let [res (th/run-task! :storage-gc-touched {})] (t/is (= 0 (:freeze res))) (t/is (= 0 (:delete res))))) - - (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 3}))] + ;; still not eligible: touched-at (now+1h) is beyond the threshold + (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 2}))] (let [res (th/run-task! :storage-gc-touched {})] + (t/is (= 0 (:freeze res))) + (t/is (= 0 (:delete res))))) + + ;; eligible: marked for deletion immediately, without any extra delay + (let [clock (ct/plus now {:hours 3})] + (binding [ct/*clock* (ct/fixed-clock clock)] + (let [res (th/run-task! :storage-gc-touched {})] + (t/is (= 0 (:freeze res))) + (t/is (= 1 (:delete res))))) + + (let [row (th/db-exec-one! ["select deleted_at from storage_object where id = ?" (:id object1)])] + (t/is (ct/is-before-or-equal? (:deleted-at row) (ct/plus clock {:seconds 1}))))) + + ;; removed on the next deleted gc run + (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 4}))] + (let [res (th/run-task! :storage-gc-deleted {})] + (t/is (= 1 (:deleted res))))))) + +(t/deftest touched-gc-task-skip-delay + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (sto/content "content1") + now (ct/now) + + object1 (sto/put-object! storage {::sto/content content + ::sto/touched-at now + :bucket "tempfile" + :content-type "text/plain"})] + + ;; too recent: not processed without skip-delay + (binding [ct/*clock* (ct/fixed-clock now)] + (let [res (th/run-task! :storage-gc-touched {})] + (t/is (= 0 (:freeze res))) + (t/is (= 0 (:delete res))))) + + ;; processed immediately with skip-delay + (binding [ct/*clock* (ct/fixed-clock now)] + (let [res (th/run-task! :storage-gc-touched {:skip-delay true})] (t/is (= 0 (:freeze res))) (t/is (= 1 (:delete res))))) + ;; and marked for deletion without any additional delay + (let [row (th/db-exec-one! ["select deleted_at from storage_object where id = ?" (:id object1)])] + (t/is (ct/is-before-or-equal? (:deleted-at row) (ct/plus now {:seconds 1})))))) - (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 1}))] +(t/deftest storage-gc-deleted-immediate + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (sto/content "content1") + object (sto/put-object! storage {::sto/content content + :content-type "text/plain"})] + + ;; mark as deleted right now + (th/db-exec! ["update storage_object set deleted_at = ?" (ct/now)]) + + ;; the deleted gc removes it on the next run + (let [res (th/run-task! :storage-gc-deleted {})] + (t/is (= 1 (:deleted res)))))) + +(t/deftest objects-gc-task-skip-delay + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + prof (th/create-profile* 1) + proj (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + mfile {:filename "sample.jpg" + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg" + :size 312043} + params {::th/type :upload-file-media-object + ::rpc/profile-id (:id prof) + :file-id (:id file) + :is-local true + :name "testfile" + :content mfile} + out1 (th/command! params) + out2 (th/command! params)] + + (t/is (nil? (:error out1))) + (t/is (nil? (:error out2))) + + (let [result-1 (:result out1) + result-2 (:result out2)] + + ;; mark as deleted but in the future (not yet eligible) + (th/db-update! :file-media-object + {:deleted-at (ct/in-future {:days 1})} + {:id (:id result-1)}) + + ;; without skip-delay the future deleted row is not processed + (let [res (th/run-task! :objects-gc {})] + (t/is (= 0 (:processed res)))) + + ;; with skip-delay it is processed immediately + (let [res (th/run-task! :objects-gc {:skip-delay true})] + (t/is (= 1 (:processed res))))))) + +(t/deftest put-object-write-failure-leaves-pending-row + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + ;; Point the fs backend at a path that is actually a file so the + ;; blob write fails. + blocked (fs/path "/tmp/penpot" (str "blocked-" (uuid/next))) + _ (spit (str blocked) "x")] + (try + (let [broken (assoc-in storage [::sto/backends :fs ::sto.fs/directory] (str blocked)) + content (sto/content "content") + ex (try + (sto/put-object! broken {::sto/content content + :content-type "text/plain"}) + nil + (catch Throwable cause cause))] + (t/is (some? ex)) + + ;; the pending row stays behind and is reclaimed asynchronously + ;; by the :storage-pending-gc task + (let [rows (th/db-query :storage-object {:status "pending"})] + (t/is (= 1 (count rows))) + + (th/db-update! :storage-object + {:created-at (ct/in-past {:days 2})} + {:id (:id (first rows))}) + + (let [res (th/run-task! :storage-pending-gc {})] + (t/is (= 1 (:processed res)))) + + (let [row (th/db-exec-one! ["select count(*) from storage_object"])] + (t/is (= 0 (:count row)))))) + (finally + (fs/delete blocked))))) + +(t/deftest pending-gc-reclaims-unpromoted-object + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (sto/content "content") + object (sto/put-object! storage {::sto/content content + :content-type "text/plain"}) + path (sto/get-object-path storage object)] + + ;; valid objects are never reclaimed + (let [res (th/run-task! :storage-pending-gc {})] + (t/is (= 0 (:processed res)))) + + ;; simulate a crash: the object was created but never promoted + (th/db-update! :storage-object {:status "pending" + :created-at (ct/in-past {:days 2})} + {:id (:id object)}) + + (t/is (fs/exists? path)) + + (let [res (th/run-task! :storage-pending-gc {})] + (t/is (= 1 (:processed res)))) + + ;; both the row and the orphaned blob are removed + (let [row (th/db-exec-one! ["select count(*) from storage_object where id = ?" (:id object)])] + (t/is (= 0 (:count row)))) + (t/is (not (fs/exists? path))))) + +(t/deftest pending-objects-excluded-from-gc-touched + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (sto/content "content") + object (sto/put-object! storage {::sto/content content + ::sto/touched-at (ct/now) + :content-type "text/plain"})] + + ;; mark it pending and touched in the past + (th/db-update! :storage-object {:status "pending" + :touched-at (ct/in-past {:days 1})} + {:id (:id object)}) + + (binding [ct/*clock* (ct/fixed-clock (ct/now))] + (let [res (th/run-task! :storage-gc-touched {})] + (t/is (= 0 (:freeze res))) + (t/is (= 0 (:delete res))))) + + ;; still present and not marked as deleted + (let [row (th/db-exec-one! ["select * from storage_object where id = ?" (:id object)])] + (t/is (some? row)) + (t/is (nil? (:deleted-at row)))))) + +(t/deftest pending-objects-excluded-from-get + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (sto/content "content") + object (sto/put-object! storage {::sto/content content + :content-type "text/plain"})] + + (t/is (some? (sto/get-object storage (:id object)))) + + (th/db-update! :storage-object {:status "pending"} {:id (:id object)}) + + (t/is (nil? (sto/get-object storage (:id object)))))) + +(t/deftest pending-objects-excluded-from-dedup + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (-> (sto/content "content") + (sto/wrap-with-hash "same-hash")) + object1 (sto/put-object! storage {::sto/content content + ::sto/deduplicate? true + :bucket "file-media-object" + :content-type "text/plain"})] + + ;; mark the only matching row as pending + (th/db-update! :storage-object {:status "pending"} {:id (:id object1)}) + + (let [object2 (sto/put-object! storage {::sto/content content + ::sto/deduplicate? true + :bucket "file-media-object" + :content-type "text/plain"})] + (t/is (not= (:id object1) (:id object2)))))) + +(t/deftest dedup-reuses-existing-blob + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (-> (sto/content "content") + (sto/wrap-with-hash "same-hash")) + object1 (sto/put-object! storage {::sto/content content + ::sto/deduplicate? true + :bucket "file-media-object" + :content-type "text/plain"}) + object2 (sto/put-object! storage {::sto/content content + ::sto/deduplicate? true + :bucket "file-media-object" + :content-type "text/plain"})] + (t/is (= (:id object1) (:id object2))) + (let [row (th/db-exec-one! ["select count(*) from storage_object"])] + (t/is (= 1 (:count row)))))) + +(t/deftest dedup-repairs-stale-object + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (-> (sto/content "content") + (sto/wrap-with-hash "same-hash")) + object1 (sto/put-object! storage {::sto/content content + ::sto/deduplicate? true + :bucket "file-media-object" + :content-type "text/plain"})] + + ;; remove the physical blob to simulate a stale/broken object + (let [path (sto/get-object-path storage object1)] + (fs/delete path)) + + ;; re-uploading identical content repairs the same reference in place + (let [object2 (sto/put-object! storage {::sto/content content + ::sto/deduplicate? true + :bucket "file-media-object" + :content-type "text/plain"})] + (t/is (= (:id object1) (:id object2))) + + ;; the row stays live: no tombstone and no extra row + (let [row (th/db-exec-one! ["select status, deleted_at from storage_object where id = ?" (:id object1)])] + (t/is (= "valid" (:status row))) + (t/is (nil? (:deleted-at row)))) + + (let [row (th/db-exec-one! ["select count(*) from storage_object"])] + (t/is (= 1 (:count row)))) + + ;; the repaired blob is readable again under the original id + (t/is (= "content" (slurp (sto/get-object-data storage object2))))))) + +(t/deftest gc-deleted-removes-broken-object + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (sto/content "content") + object (sto/put-object! storage {::sto/content content + :content-type "text/plain"})] + + ;; mark as deleted and remove the physical blob + (th/db-update! :storage-object {:deleted-at (ct/in-past {:minutes 1})} + {:id (:id object)}) + (let [path (sto/get-object-path storage object)] + (fs/delete path)) + + ;; the deleted gc removes the row without error even though the blob is + ;; missing (the physical deletion is best-effort) + (let [res (th/run-task! :storage-gc-deleted {})] + (t/is (= 1 (:deleted res)))) + + (let [row (th/db-exec-one! ["select count(*) from storage_object where id = ?" (:id object)])] + (t/is (= 0 (:count row)))))) + +(t/deftest pending-objects-excluded-from-gc-deleted + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (sto/content "content") + object (sto/put-object! storage {::sto/content content + :content-type "text/plain"})] + ;; mark as pending + deleted in the past + (th/db-update! :storage-object {:status "pending" + :deleted-at (ct/in-past {:minutes 1})} + {:id (:id object)}) + ;; gc-deleted skips it because status != 'valid' + (let [res (th/run-task! :storage-gc-deleted {})] + (t/is (= 0 (:deleted res)))) + ;; row still exists (with deleted_at set — we set it above) + (let [row (th/db-exec-one! ["select count(*) from storage_object where id = ?" + (:id object)])] + (t/is (= 1 (:count row)))))) + +(t/deftest gc-deleted-gives-up-after-max-attempts + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (sto/content "content") + object (sto/put-object! storage {::sto/content content + :content-type "text/plain"})] + + (th/db-update! :storage-object {:deleted-at (ct/in-past {:minutes 1}) + :deletion_attempts 6} + {:id (:id object)}) + + (with-mocks [_mock {:target 'app.storage.impl/del-objects-in-bulk + :return (fn [_ ids] (set ids))}] (let [res (th/run-task! :storage-gc-deleted {})] (t/is (= 0 (:deleted res))))) - (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 2}))] - (let [res (th/run-task! :storage-gc-deleted {})] - (t/is (= 0 (:deleted res))))))) + (let [row (th/db-exec-one! ["select count(*) from storage_object where id = ?" (:id object)])] + (t/is (= 0 (:count row)))))) + +(t/deftest dedup-reuses-existing-blob-with-touch + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (-> (sto/content "content") + (sto/wrap-with-hash "same-hash")) + t0 (ct/now) + params {::sto/deduplicate? true + ::sto/touch true + :bucket "file-media-object" + :content-type "text/plain"} + object1 (binding [ct/*clock* (ct/fixed-clock t0)] + (sto/put-object! storage (assoc params ::sto/content content)))] + + ;; a touched hit reuses the object and updates its touched_at + (let [object2 (binding [ct/*clock* (ct/fixed-clock (ct/plus t0 {:hours 1}))] + (sto/put-object! storage (assoc params ::sto/content content)))] + (t/is (= (:id object1) (:id object2))) + + (let [row (th/db-exec-one! ["select touched_at from storage_object where id = ?" (:id object1)])] + (t/is (ct/is-after? (:touched-at row) t0)))) + + ;; with the blob removed, the touched hit repairs the stale row in + ;; place: the same id is kept, the row is not deleted and touched_at + ;; is left untouched (the touch flag only applies to healthy hits) + (let [path (sto/get-object-path storage object1)] + (fs/delete path)) + + (let [object3 (binding [ct/*clock* (ct/fixed-clock (ct/plus t0 {:hours 2}))] + (sto/put-object! storage (assoc params ::sto/content content)))] + (t/is (= (:id object1) (:id object3))) + + (let [row (th/db-exec-one! ["select deleted_at, touched_at from storage_object where id = ?" (:id object1)])] + (t/is (nil? (:deleted-at row))) + ;; the touch flag does not apply to repairs: touched_at was last + ;; set by the healthy hit and is not bumped by the repair + (t/is (ct/is-before? (:touched-at row) (ct/plus t0 {:hours 2}))))))) + +(t/deftest put-object-repair-failure-leaves-row-intact + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (-> (sto/content "content") + (sto/wrap-with-hash "same-hash")) + object (sto/put-object! storage {::sto/content content + ::sto/deduplicate? true + :bucket "file-media-object" + :content-type "text/plain"}) + path (sto/get-object-path storage object) + + ;; Point the fs backend at a path that is actually a file so the + ;; blob write fails. + blocked (fs/path "/tmp/penpot" (str "blocked-" (uuid/next))) + _ (spit (str blocked) "x")] + (try + ;; remove the physical blob to simulate a stale/broken object + (fs/delete path) + + (let [broken (assoc-in storage [::sto/backends :fs ::sto.fs/directory] (str blocked)) + ex (try + (sto/put-object! broken {::sto/content content + ::sto/deduplicate? true + :bucket "file-media-object" + :content-type "text/plain"}) + nil + (catch Throwable cause cause))] + (t/is (some? ex)) + + ;; the failed repair leaves the original row exactly as it was: + ;; live and valid, so a later upload can retry the healing + (let [row (th/db-exec-one! ["select status, deleted_at from storage_object where id = ?" (:id object)])] + (t/is (= "valid" (:status row))) + (t/is (nil? (:deleted-at row)))) + + (let [row (th/db-exec-one! ["select count(*) from storage_object"])] + (t/is (= 1 (:count row))))) + (finally + (fs/delete blocked))))) + +(t/deftest upload-chunks-exclude-pending + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + mfile {:filename "chunk" + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg" + :size 312043} + session-id (-> (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 1}) + :result :session-id) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] + + (t/is (nil? (:error out))) + + ;; mark all the chunks of this session as pending (simulates rows that + ;; were never promoted) + (th/db-exec! ["update storage_object set status = 'pending' where (metadata->>'~:upload-id') = ?" + (str session-id)]) + + ;; assembling fails because no chunk is visible anymore + (let [assemble-out (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id session-id + :file-id (:id file) + :is-local true + :name "assembled-image" + :mtype "image/jpeg"})] + (t/is (some? (:error assemble-out)))))) + +(defn- fake-s3-backend + [] + {::sto/type :s3 + ::sto.s3/client (reify S3AsyncClient) + ::sto.s3/presigner (reify S3Presigner)}) + +(t/deftest s3-exists-object-returns-true-on-found + (with-mocks [mock {:target 'app.storage.s3/head-object + :return (p/resolved {})}] + (t/is (true? (impl/exists-object? (fake-s3-backend) {:id (uuid/next)}))) + (t/is (= 1 (:call-count @mock))))) + +(t/deftest s3-exists-object-returns-false-on-missing-key + (with-mocks [mock {:target 'app.storage.s3/head-object + :return (p/rejected (-> (NoSuchKeyException/builder) + (.message "no key") + (.build)))}] + (t/is (false? (impl/exists-object? (fake-s3-backend) {:id (uuid/next)}))) + ;; a missing key is a definitive answer: no retries + (t/is (= 1 (:call-count @mock))))) + +(t/deftest s3-exists-object-retries-transient-errors + (let [calls (atom 0)] + (with-mocks [_mock {:target 'app.storage.s3/head-object + :return (fn [& _] + (swap! calls inc) + (if (< @calls 3) + (p/rejected (RuntimeException. "boom")) + (p/resolved {})))}] + (t/is (true? (impl/exists-object? (fake-s3-backend) {:id (uuid/next)}))) + (t/is (= 3 @calls))))) + +(t/deftest s3-exists-object-throws-after-retries-exhausted + (with-mocks [mock {:target 'app.storage.s3/head-object + :return (p/rejected (RuntimeException. "boom"))}] + ;; p/await returns the rejection wrapped in an ExecutionException + (let [ex (try + (impl/exists-object? (fake-s3-backend) {:id (uuid/next)}) + nil + (catch Throwable cause cause))] + (t/is (some? ex)) + (t/is (= "boom" (ex-message (ex-cause ex))))) + ;; one initial attempt plus max-retries + (t/is (= 4 (:call-count @mock))))) diff --git a/backend/test/backend_tests/tasks_telemetry_test.clj b/backend/test/backend_tests/tasks_telemetry_test.clj index 07b8f7c7f6..e12af0553b 100644 --- a/backend/test/backend_tests/tasks_telemetry_test.clj +++ b/backend/test/backend_tests/tasks_telemetry_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.tasks-telemetry-test (:require @@ -710,6 +710,19 @@ (t/is (not (contains? (:props result) :route))) (t/is (not (contains? (:props result) :label))))) +(t/deftest test-filter-telemetry-props-organization-sso-failure-keeps-reason + (let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props) + organization-id (uuid/next) + result (ftp {:source "backend" + :name "organization-sso-auth-failed" + :type "action" + :props {:organization-id organization-id + :failure-reason "access-denied" + :unsafe-label "should-be-stripped"}})] + (t/is (= {:organization-id organization-id + :failure-reason "access-denied"} + (:props result))))) + (t/deftest test-filter-telemetry-props-navigate-keeps-route-and-ids ;; Frontend navigate events keep specific routing keys: :route, ;; :file-id, :team-id, :page-id. These ids are strings because diff --git a/backend/test/backend_tests/test_files/file-with-library.penpot b/backend/test/backend_tests/test_files/file-with-library.penpot new file mode 100644 index 0000000000..c17bb86a3d Binary files /dev/null and b/backend/test/backend_tests/test_files/file-with-library.penpot differ diff --git a/backend/test/backend_tests/util_blob_test.clj b/backend/test/backend_tests/util_blob_test.clj index f6e129f7ce..bdd502de51 100644 --- a/backend/test/backend_tests/util_blob_test.clj +++ b/backend/test/backend_tests/util_blob_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.util-blob-test (:require diff --git a/backend/test/backend_tests/util_objects_map_test.clj b/backend/test/backend_tests/util_objects_map_test.clj index 6cf9fab519..c73bf5fa86 100644 --- a/backend/test/backend_tests/util_objects_map_test.clj +++ b/backend/test/backend_tests/util_objects_map_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.util-objects-map-test (:require diff --git a/backend/test/backend_tests/util_pointer_map_test.clj b/backend/test/backend_tests/util_pointer_map_test.clj index 6d206d9f9a..ce39715c4b 100644 --- a/backend/test/backend_tests/util_pointer_map_test.clj +++ b/backend/test/backend_tests/util_pointer_map_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.util-pointer-map-test (:require diff --git a/backend/test/backend_tests/util_ssrf_test.clj b/backend/test/backend_tests/util_ssrf_test.clj index 2dadb8282a..725a4c9a55 100644 --- a/backend/test/backend_tests/util_ssrf_test.clj +++ b/backend/test/backend_tests/util_ssrf_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.util-ssrf-test (:require @@ -13,11 +13,25 @@ [clojure.test :as t])) (t/deftest validate-url-allows-public-https - (t/is (true? (ssrf/safe-url? "https://example.com/foo"))) - (t/is (true? (ssrf/safe-url? "https://example.com:8080/path?q=1")))) + (let [original ssrf/resolve-host] + (with-redefs [ssrf/resolve-host + (fn [hostname] + (if (= hostname "example.com") + (into-array java.net.InetAddress + [(java.net.InetAddress/getByName "93.184.216.34")]) + (original hostname)))] + (t/is (true? (ssrf/safe-url? "https://example.com/foo"))) + (t/is (true? (ssrf/safe-url? "https://example.com:8080/path?q=1")))))) (t/deftest validate-url-allows-public-http - (t/is (true? (ssrf/safe-url? "http://example.com/foo")))) + (let [original ssrf/resolve-host] + (with-redefs [ssrf/resolve-host + (fn [hostname] + (if (= hostname "example.com") + (into-array java.net.InetAddress + [(java.net.InetAddress/getByName "93.184.216.34")]) + (original hostname)))] + (t/is (true? (ssrf/safe-url? "http://example.com/foo")))))) (t/deftest validate-url-blocks-disallowed-schemes (t/is (false? (ssrf/safe-url? "file:///etc/passwd"))) @@ -66,6 +80,24 @@ (t/is (false? (ssrf/safe-url? "http://[fd00::1]/foo"))) (t/is (false? (ssrf/safe-url? "http://[fc00::1]/foo")))) +(t/deftest validate-url-blocks-nat64-encoded-metadata + ;; 64:ff9b::a9fe:a9fe embeds 169.254.169.254 (cloud metadata) + (t/is (false? (ssrf/safe-url? "http://[64:ff9b::a9fe:a9fe]/latest/meta-data/")))) + +(t/deftest validate-url-blocks-nat64-encoded-loopback + ;; 64:ff9b::7f00:0001 embeds 127.0.0.1 + (t/is (false? (ssrf/safe-url? "http://[64:ff9b::7f00:1]/foo")))) + +(t/deftest validate-url-blocks-6to4-encoded-private + ;; 2002:a00:1:: embeds 10.0.0.1; 2002:c0a8:101:: embeds 192.168.1.1 + (t/is (false? (ssrf/safe-url? "http://[2002:a00:1::1]/foo"))) + (t/is (false? (ssrf/safe-url? "http://[2002:c0a8:101::1]/foo")))) + +(t/deftest validate-url-blocks-teredo-encoded-addresses + ;; Teredo server prefix 2001:0000::/32 + (t/is (false? (ssrf/safe-url? + "http://[2001:0000:4136:e378:8000:63bf:3fff:fdd2]/foo")))) + (t/deftest validate-url-blocks-encoded-loopback ;; Decimal encoding of 127.0.0.1 = 2130706433 ;; InetAddress normalizes this to 127.0.0.1 diff --git a/backend/test/e2e/asset-download.test.mjs b/backend/test/e2e/asset-download.test.mjs new file mode 100644 index 0000000000..99bb0446ba --- /dev/null +++ b/backend/test/e2e/asset-download.test.mjs @@ -0,0 +1,145 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import { + setupTestProfile, + createAccessToken, +} from "./helpers/auth.mjs"; +import { rpcPost, getAsset } from "./helpers/client.mjs"; +import { parseSSE, extractResult } from "./helpers/sse.mjs"; + +async function createAndExport(cookie, projectId) { + const createRes = await rpcPost( + "create-file", + { name: "E2E Asset Test", projectId }, + { cookieToken: cookie } + ); + assert.equal(createRes.status, 200); + const fileId = createRes.body.id; + + const exportRes = await rpcPost( + "export-binfile", + { fileId, includeLibraries: false, embedAssets: true }, + { cookieToken: cookie } + ); + assert.equal(exportRes.status, 200); + const assetUrl = extractResult(parseSSE(exportRes.body)); + return assetUrl; +} + +function extractAssetId(assetUrl) { + const match = assetUrl.match(/\/assets\/by-id\/([0-9a-f-]+)/); + return match ? match[1] : null; +} + +describe("asset download", () => { + let profile, cookie, assetUrl, assetId; + + before(async () => { + const setup = await setupTestProfile(); + profile = setup.profile; + cookie = setup.cookie; + + assetUrl = await createAndExport(cookie, profile.defaultProjectId); + assetId = extractAssetId(assetUrl); + assert.ok(assetId, `should extract asset id from URL: ${assetUrl}`); + }); + + it("asset download with cookie auth succeeds", async () => { + // In devenv, nginx's @handle_redirect intercepts the backend's 307 and + // proxies to S3 directly. The client sees 200 with file content, not 307. + const res = await getAsset(assetId, { cookieToken: cookie }); + assert.equal(res.status, 200, `expected 200, got ${res.status}`); + assert.ok( + res.body.length > 0 || typeof res.body === "object", + "response should have content" + ); + }); + + it("asset download with access token auth succeeds", async () => { + const tokenObj = await createAccessToken(cookie, "e2e-asset-test"); + const accessToken = tokenObj.token; + + const res = await getAsset(assetId, { accessToken }); + assert.equal(res.status, 200, `expected 200, got ${res.status}`); + }); + + it("asset download without auth returns 401", async () => { + const res = await getAsset(assetId, {}); + assert.equal(res.status, 401, `expected 401, got ${res.status}`); + }); + + it("asset download returns file content through nginx proxy", async () => { + // The full flow: backend returns 307 with S3 presigned URL, + // nginx intercepts and proxies to S3, client gets 200 with content. + const res = await getAsset(assetId, { cookieToken: cookie }); + assert.equal(res.status, 200); + // Response should be a .penpot file (binary/zip content) + assert.ok(res.body, "response should have body"); + }); + + it("follow S3 redirect WITH auth header (bug repro)", async () => { + // In devenv, nginx's @handle_redirect intercepts the 307 and proxies to + // S3 server-side, only forwarding the Host header from X-Host. The client's + // Authorization header is NOT forwarded to S3, so the request succeeds. + // + // In production (no nginx proxy), the backend returns 307 directly. The HTTP + // client follows the redirect and forwards the Authorization: Token header to + // S3, which conflicts with the presigned URL's X-Amz-* params and returns + // 400 InvalidArgument. + // + // This test documents the devenv behavior: nginx strips the auth header + // when proxying to S3, so the download succeeds. + const res = await getAsset(assetId, { cookieToken: cookie }); + assert.equal(res.status, 200, "through nginx, download succeeds"); + assert.ok(res.body, "should have file content"); + }); + + it("full export-to-download flow works end-to-end", async () => { + const url = await createAndExport(cookie, profile.defaultProjectId); + const id = extractAssetId(url); + assert.ok(id); + + const res = await getAsset(id, { cookieToken: cookie }); + assert.equal(res.status, 200); + }); + + it("asset URL is accessible immediately after export", async () => { + const url = await createAndExport(cookie, profile.defaultProjectId); + const id = extractAssetId(url); + assert.ok(id); + + const res = await getAsset(id, { cookieToken: cookie }); + assert.equal(res.status, 200, "asset should be accessible right after export"); + }); + + it("token-only: export then download asset with same token", async () => { + const tokenObj = await createAccessToken(cookie, "e2e-token-export-test"); + const token = tokenObj.token; + + const createRes = await rpcPost( + "create-file", + { name: "E2E Token Export Test", projectId: profile.defaultProjectId }, + { accessToken: token } + ); + assert.equal(createRes.status, 200); + const fileId = createRes.body.id; + + const exportRes = await rpcPost( + "export-binfile", + { fileId, includeLibraries: false, embedAssets: true }, + { accessToken: token } + ); + assert.equal(exportRes.status, 200); + + const events = parseSSE(exportRes.body); + const url = extractResult(events); + assert.ok(url, "should get an asset URL from export"); + + const id = extractAssetId(url); + assert.ok(id, `should extract asset id from URL: ${url}`); + + const res = await getAsset(id, { accessToken: token }); + assert.equal(res.status, 200, `expected 200, got ${res.status}`); + assert.ok(res.body, "response should have file content"); + }); +}); diff --git a/backend/test/e2e/auth-flow.test.mjs b/backend/test/e2e/auth-flow.test.mjs new file mode 100644 index 0000000000..7f9fbb34fd --- /dev/null +++ b/backend/test/e2e/auth-flow.test.mjs @@ -0,0 +1,78 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + createDemoProfile, + login, + setupTestProfile, +} from "./helpers/auth.mjs"; +import { rpcPost } from "./helpers/client.mjs"; + +describe("auth flow", () => { + it("creates a demo profile", async () => { + const { email, password } = await createDemoProfile(); + assert.match(email, /^demo-.*\.demo@example\.com$/); + assert.ok(password.length > 0); + }); + + it("logs in with valid credentials", async () => { + const { email, password } = await createDemoProfile(); + const { profile, cookie } = await login(email, password); + + assert.equal(profile.email, email); + assert.equal(profile.isDemo, true); + assert.ok(profile.id, "profile should have id"); + assert.ok(profile.defaultProjectId, "profile should have defaultProjectId"); + assert.ok(profile.defaultTeamId, "profile should have defaultTeamId"); + assert.ok(cookie, "cookie should be set"); + }); + + it("login sets session cookie", async () => { + const { email, password } = await createDemoProfile(); + const { cookie } = await login(email, password); + assert.ok(cookie, "auth-token cookie should be extracted"); + assert.ok(cookie.length > 10, "cookie should have meaningful length"); + }); + + it("login fails with wrong password", async () => { + const { email } = await createDemoProfile(); + try { + await login(email, "wrong-password"); + assert.fail("should have thrown"); + } catch (e) { + assert.ok(e.message.includes("Login failed")); + } + }); + + it("login fails with non-existent email", async () => { + try { + await login("nonexistent@example.com", "some-password"); + assert.fail("should have thrown"); + } catch (e) { + assert.ok(e.message.includes("Login failed")); + } + }); + + it("authenticated RPC with cookie", async () => { + const { profile, cookie } = await setupTestProfile(); + const res = await rpcPost("get-profile", {}, { cookieToken: cookie }); + assert.equal(res.status, 200); + assert.equal(res.body.id, profile.id); + assert.equal(res.body.email, profile.email); + }); + + it("unauthenticated RPC returns anonymous profile", async () => { + const res = await rpcPost("get-profile", {}); + assert.equal(res.status, 200); + // Anonymous profile has uuid/zero as id + assert.equal(res.body.id, "00000000-0000-0000-0000-000000000000"); + }); + + it("setupTestProfile returns all fields", async () => { + const { profile, cookie, email, password } = await setupTestProfile(); + assert.ok(profile.id); + assert.ok(profile.defaultProjectId); + assert.ok(cookie); + assert.ok(email); + assert.ok(password); + }); +}); diff --git a/backend/test/e2e/config.mjs b/backend/test/e2e/config.mjs new file mode 100644 index 0000000000..697f20f8c4 --- /dev/null +++ b/backend/test/e2e/config.mjs @@ -0,0 +1,7 @@ +const config = Object.freeze({ + baseUrl: process.env.PENPOT_BASE_URL || "http://localhost:3450", + email: process.env.PENPOT_EMAIL || null, + password: process.env.PENPOT_PASSWORD || null, +}); + +export default config; diff --git a/backend/test/e2e/export-binfile.test.mjs b/backend/test/e2e/export-binfile.test.mjs new file mode 100644 index 0000000000..f6be598ec5 --- /dev/null +++ b/backend/test/e2e/export-binfile.test.mjs @@ -0,0 +1,87 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import { setupTestProfile } from "./helpers/auth.mjs"; +import { rpcPost } from "./helpers/client.mjs"; +import { parseSSE, extractResult } from "./helpers/sse.mjs"; + +async function createFile(cookie, projectId, name = "E2E Test File") { + const res = await rpcPost( + "create-file", + { name, projectId }, + { cookieToken: cookie } + ); + assert.equal(res.status, 200, `create-file failed: ${JSON.stringify(res.body)}`); + return res.body; +} + +async function exportFile(cookie, fileId) { + const res = await rpcPost( + "export-binfile", + { + fileId, + includeLibraries: false, + embedAssets: true, + }, + { cookieToken: cookie } + ); + assert.equal(res.status, 200, `export-binfile failed: ${JSON.stringify(res.body)}`); + + const events = parseSSE(res.body); + const assetUrl = extractResult(events); + return assetUrl; +} + +describe("export-binfile", () => { + let profile, cookie; + + before(async () => { + const setup = await setupTestProfile(); + profile = setup.profile; + cookie = setup.cookie; + }); + + it("creates a file via API", async () => { + const file = await createFile(cookie, profile.defaultProjectId); + assert.ok(file.id, "file should have an id"); + assert.equal(file.name, "E2E Test File"); + }); + + it("export returns an asset URL", async () => { + const file = await createFile(cookie, profile.defaultProjectId); + const assetUrl = await exportFile(cookie, file.id); + assert.ok( + typeof assetUrl === "string" && assetUrl.includes("/assets/by-id/"), + `asset URL should contain /assets/by-id/, got: ${assetUrl}` + ); + assert.match(assetUrl, /\/assets\/by-id\/[0-9a-f-]+$/); + }); + + it("export with invalid file-id returns error", async () => { + const fakeId = "00000000-0000-0000-0000-000000000000"; + const res = await rpcPost( + "export-binfile", + { + fileId: fakeId, + includeLibraries: false, + embedAssets: true, + }, + { cookieToken: cookie } + ); + assert.ok( + res.body.type || res.status !== 200, + "should return error for non-existent file" + ); + }); + + it("export requires authentication", async () => { + const res = await rpcPost("export-binfile", { + fileId: "00000000-0000-0000-0000-000000000000", + includeLibraries: false, + embedAssets: true, + }); + assert.ok( + res.body.type || res.status !== 200, + "should require authentication" + ); + }); +}); diff --git a/backend/test/e2e/helpers/auth.mjs b/backend/test/e2e/helpers/auth.mjs new file mode 100644 index 0000000000..07d34ef0f0 --- /dev/null +++ b/backend/test/e2e/helpers/auth.mjs @@ -0,0 +1,38 @@ +import { rpcPost, extractCookie } from "./client.mjs"; + +export async function createDemoProfile() { + const res = await rpcPost("create-demo-profile", {}); + if (res.body.type === "validation" || res.body.type === "restriction") { + throw new Error( + `Failed to create demo profile: ${res.body.code} - ${res.body.hint || ""}` + ); + } + return { email: res.body.email, password: res.body.password }; +} + +export async function login(email, password) { + const res = await rpcPost("login-with-password", { email, password }); + if (res.status !== 200 || res.body.type) { + throw new Error( + `Login failed: ${JSON.stringify(res.body)}` + ); + } + const cookie = extractCookie(res.setCookie); + return { profile: res.body, cookie }; +} + +export async function createAccessToken(cookie, name = "e2e-test-token") { + const res = await rpcPost("create-access-token", { name }, { cookieToken: cookie }); + if (res.status !== 200 || res.body.type) { + throw new Error( + `Create access token failed: ${JSON.stringify(res.body)}` + ); + } + return res.body; +} + +export async function setupTestProfile() { + const { email, password } = await createDemoProfile(); + const { profile, cookie } = await login(email, password); + return { profile, cookie, email, password }; +} diff --git a/backend/test/e2e/helpers/client.mjs b/backend/test/e2e/helpers/client.mjs new file mode 100644 index 0000000000..1bfe8750d6 --- /dev/null +++ b/backend/test/e2e/helpers/client.mjs @@ -0,0 +1,86 @@ +import config from "../config.mjs"; + +async function parseResponse(response) { + const contentType = response.headers.get("content-type") || ""; + const setCookie = response.headers.get("set-cookie") || null; + + let body; + if (contentType.includes("application/json")) { + body = await response.json(); + } else { + body = await response.text(); + } + + return { + status: response.status, + headers: response.headers, + body, + setCookie, + }; +} + +export function extractCookie(setCookieHeader, name = "auth-token") { + if (!setCookieHeader) return null; + const match = setCookieHeader.match(new RegExp(`${name}=([^;]+)`)); + return match ? match[1] : null; +} + +export async function rpcPost(method, body = {}, { cookieToken, accessToken } = {}) { + const headers = { + "Content-Type": "application/json", + Accept: "application/json", + }; + if (cookieToken) { + headers.Cookie = `auth-token=${cookieToken}`; + } + if (accessToken) { + headers.Authorization = `Token ${accessToken}`; + } + + const response = await fetch(`${config.baseUrl}/api/main/methods/${method}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + return parseResponse(response); +} + +export async function multipartPost(method, formData, { cookieToken } = {}) { + const headers = { + Accept: "application/json", + }; + if (cookieToken) { + headers.Cookie = `auth-token=${cookieToken}`; + } + + const response = await fetch(`${config.baseUrl}/api/main/methods/${method}`, { + method: "POST", + headers, + body: formData, + }); + + return parseResponse(response); +} + +export async function getAsset( + id, + { cookieToken, accessToken, redirect = "manual" } = {} +) { + const headers = { Accept: "application/json" }; + if (cookieToken) { + headers.Cookie = `auth-token=${cookieToken}`; + } + if (accessToken) { + headers.Authorization = `Token ${accessToken}`; + } + + const response = await fetch(`${config.baseUrl}/assets/by-id/${id}`, { + method: "GET", + headers, + redirect, + }); + + return parseResponse(response); +} + diff --git a/backend/test/e2e/helpers/sse.mjs b/backend/test/e2e/helpers/sse.mjs new file mode 100644 index 0000000000..02f3845d20 --- /dev/null +++ b/backend/test/e2e/helpers/sse.mjs @@ -0,0 +1,72 @@ +import { createParser } from "eventsource-parser"; + +export function parseSSE(text) { + const events = []; + const parser = createParser({ + onEvent(event) { + events.push({ event: event.event || "message", data: event.data }); + }, + }); + parser.feed(text); + return events; +} + +export function extractResult(events) { + const endEvent = events.find((e) => e.event === "end"); + if (!endEvent) { + const errEvent = events.find((e) => e.event === "error"); + if (errEvent) { + throw new Error(`SSE error: ${errEvent.data}`); + } + throw new Error(`No end event found in SSE stream. Events: ${JSON.stringify(events)}`); + } + + const raw = JSON.parse(endEvent.data); + + // Transit JSON verbose format: + // For URIs (e.g. asset URL): {"~#uri":"https://..."} + // For objects: {"~:key":"val",...} or ["^ ","~:key","val",...] + // For strings: plain string + if (raw && typeof raw === "object") { + // Tagged URI + if ("~#uri" in raw) { + return raw["~#uri"]; + } + // Transit map with ~:value key + if ("~:value" in raw) { + const value = raw["~:value"]; + if (Array.isArray(value)) { + return transitArrayToObj(value); + } + return value; + } + // Direct transit map (keys starting with ~:) + const firstKey = Object.keys(raw)[0]; + if (firstKey && firstKey.startsWith("~:")) { + return transitMapToObj(raw); + } + } + + return raw; +} + +function transitArrayToObj(arr) { + // Transit verbose object: ["^ ","~:key1","val1","~:key2","val2",...] + const obj = {}; + for (let i = 1; i < arr.length; i += 2) { + const key = arr[i].replace(/^~:/, ""); + const val = arr[i + 1]; + obj[key] = val; + } + return obj; +} + +function transitMapToObj(map) { + // Transit verbose map: {"~:key1":"val1","~:key2":"val2",...} + const obj = {}; + for (const [key, val] of Object.entries(map)) { + const cleanKey = key.replace(/^~:/, ""); + obj[cleanKey] = val; + } + return obj; +} diff --git a/common/deps.edn b/common/deps.edn index edffb487f0..c5cb94c494 100644 --- a/common/deps.edn +++ b/common/deps.edn @@ -17,14 +17,14 @@ org.slf4j/slf4j-api {:mvn/version "2.0.18"} pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.43"} - selmer/selmer {:mvn/version "1.13.4"} + selmer/selmer {:mvn/version "1.13.5"} criterium/criterium {:mvn/version "0.4.6"} metosin/jsonista {:mvn/version "1.0.0" :exclusions [com.fasterxml.jackson.core/jackson-core com.fasterxml.jackson.core/jackson-databind]} - com.fasterxml.jackson.core/jackson-core {:mvn/version "2.22.1"} - com.fasterxml.jackson.core/jackson-databind {:mvn/version "2.22.1"} + com.fasterxml.jackson.core/jackson-core {:mvn/version "2.22.2"} + com.fasterxml.jackson.core/jackson-databind {:mvn/version "2.22.2"} metosin/malli {:mvn/version "0.20.1"} @@ -55,12 +55,12 @@ io.aviso/pretty {:mvn/version "1.4.4"} environ/environ {:mvn/version "1.2.0"}} - :paths ["src" "vendor" "target/classes"] + :paths ["src" "vendor" "resources" "target/classes"] :aliases {:dev {:extra-deps {org.clojure/tools.namespace {:mvn/version "1.5.1"} - thheller/shadow-cljs {:mvn/version "3.4.11"} + thheller/shadow-cljs {:mvn/version "3.5.0"} com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"} com.bhauman/rebel-readline {:mvn/version "0.1.11"} criterium/criterium {:mvn/version "0.4.6"} diff --git a/common/dev/user.clj b/common/dev/user.clj index cf6d6b5065..5a6a409341 100644 --- a/common/dev/user.clj +++ b/common/dev/user.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns user (:require diff --git a/common/package.json b/common/package.json index 09d48ef2c2..acc9432642 100644 --- a/common/package.json +++ b/common/package.json @@ -4,18 +4,18 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "type": "module", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" }, "devDependencies": { - "concurrently": "^10.0.4", + "concurrently": "^10.0.5", "nodemon": "^3.1.14", "prettier": "3.9.6", "source-map-support": "^0.5.21", - "ws": "^8.21.1" + "ws": "^8.21.3" }, "dependencies": { "date-fns": "^4.4.0" diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml index eeb471366f..77f173a685 100644 --- a/common/pnpm-lock.yaml +++ b/common/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- lockfileVersion: '9.0' settings: @@ -13,8 +114,8 @@ importers: version: 4.4.0 devDependencies: concurrently: - specifier: ^10.0.4 - version: 10.0.4 + specifier: ^10.0.5 + version: 10.0.5 nodemon: specifier: ^3.1.14 version: 3.1.14 @@ -25,8 +126,8 @@ importers: specifier: ^0.5.21 version: 0.5.21 ws: - specifier: ^8.21.1 - version: 8.21.1 + specifier: ^8.21.3 + version: 8.21.3 packages: @@ -50,9 +151,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -73,8 +174,8 @@ packages: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} - concurrently@10.0.4: - resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==} + concurrently@10.0.5: + resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==} engines: {node: '>=22'} hasBin: true @@ -234,8 +335,8 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} - ws@8.21.1: - resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -273,7 +374,7 @@ snapshots: binary-extensions@2.3.0: {} - brace-expansion@5.0.6: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -303,7 +404,7 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 - concurrently@10.0.4: + concurrently@10.0.5: dependencies: chalk: 5.6.2 rxjs: 7.8.2 @@ -357,7 +458,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.9 ms@2.1.3: {} @@ -439,7 +540,7 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 - ws@8.21.1: {} + ws@8.21.3: {} y18n@5.0.8: {} diff --git a/frontend/resources/fonts/gfonts.2025.11.28.json b/common/resources/fonts/gfonts.2025.11.28.json similarity index 100% rename from frontend/resources/fonts/gfonts.2025.11.28.json rename to common/resources/fonts/gfonts.2025.11.28.json diff --git a/common/src/app/common/UUIDv8.java b/common/src/app/common/UUIDv8.java index 97485ce89c..0976f55e33 100644 --- a/common/src/app/common/UUIDv8.java +++ b/common/src/app/common/UUIDv8.java @@ -3,7 +3,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL + Copyright (c) KALEIDOS SUBSIDIARY SL This file contains a UUIDv8 with conformance with https://datatracker.ietf.org/doc/html/draft-peabody-dispatch-new-uuid-format diff --git a/common/src/app/common/attrs.cljc b/common/src/app/common/attrs.cljc index 57a06f7332..205b02ebd4 100644 --- a/common/src/app/common/attrs.cljc +++ b/common/src/app/common/attrs.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.attrs (:require diff --git a/common/src/app/common/buffer.cljc b/common/src/app/common/buffer.cljc index 16fb3be693..16798f2891 100644 --- a/common/src/app/common/buffer.cljc +++ b/common/src/app/common/buffer.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.buffer "A collection of helpers and macros for work with byte diff --git a/common/src/app/common/colors.cljc b/common/src/app/common/colors.cljc index 22705c5980..8d0a4a486e 100644 --- a/common/src/app/common/colors.cljc +++ b/common/src/app/common/colors.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.colors (:refer-clojure :exclude [test]) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 7cbfdcc4f5..64da2a7d6a 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.data "A collection of helpers for working with data structures and other @@ -1173,6 +1173,15 @@ [key coll] (sort-by key natural-compare coll)) +(defn normalize-string + "Normalizes a string by trimming leading/trailing whitespace. + Returns empty string for nil input. Non-string input is returned unchanged." + [s] + (cond + (nil? s) "" + (string? s) (str/trim s) + :else s)) + (defn sanitize-string [s] (if s (-> s @@ -1183,6 +1192,15 @@ str/trim) "")) +(defn escape-markdown + "Escapes Markdown special characters by prefixing them with backslash. + Intended for user-controlled values embedded in Markdown messages + (e.g. Mattermost notifications)." + [s] + (if s + (str/replace (str s) #"([*_~`\[\]()>#+=\-|{}.!@\\])" (fn [[_ c]] (str "\\" c))) + "")) + (defn get-initials "Returns up to two uppercase initials extracted from a string. Non-letter prefixes in each token are ignored." diff --git a/common/src/app/common/data/macros.cljc b/common/src/app/common/data/macros.cljc index 6902e3f2ac..4a6bd5b2a9 100644 --- a/common/src/app/common/data/macros.cljc +++ b/common/src/app/common/data/macros.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.data.macros "Data retrieval & manipulation specific macros." diff --git a/common/src/app/common/data/undo_stack.cljc b/common/src/app/common/data/undo_stack.cljc index 192d3a13b8..6ac0827a60 100644 --- a/common/src/app/common/data/undo_stack.cljc +++ b/common/src/app/common/data/undo_stack.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.data.undo-stack (:refer-clojure :exclude [peek]) diff --git a/common/src/app/common/debug.clj b/common/src/app/common/debug.clj index 63d4455eda..8ca06064c0 100644 --- a/common/src/app/common/debug.clj +++ b/common/src/app/common/debug.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.debug (:require diff --git a/common/src/app/common/encoding_impl.js b/common/src/app/common/encoding_impl.js index 10cd3d1263..e83b566acd 100644 --- a/common/src/app/common/encoding_impl.js +++ b/common/src/app/common/encoding_impl.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ "use strict"; diff --git a/common/src/app/common/exceptions.cljc b/common/src/app/common/exceptions.cljc index 86785c58ff..7a4b6b1a1e 100644 --- a/common/src/app/common/exceptions.cljc +++ b/common/src/app/common/exceptions.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.exceptions "A helpers for work with exceptions." diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc index a5097a29a7..e1b12729e0 100644 --- a/common/src/app/common/features.cljc +++ b/common/src/app/common/features.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.features (:require @@ -57,6 +57,7 @@ "text-editor/v2" "text-editor-wasm/v1" "render-wasm/v1" + "wasm-export/v1" "variants/v1"}) ;; A set of features enabled by default @@ -82,7 +83,8 @@ "text-editor/v2" "text-editor-wasm/v1" "tokens/numeric-input" - "render-wasm/v1"}) + "render-wasm/v1" + "wasm-export/v1"}) ;; Features that are mainly backend only or there are a proper ;; fallback when frontend reports no support for it diff --git a/common/src/app/common/files/builder.cljc b/common/src/app/common/files/builder.cljc index c6bfd833e9..49d93407a0 100644 --- a/common/src/app/common/files/builder.cljc +++ b/common/src/app/common/files/builder.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.builder "Internal implementation of file builder. Mainly used as base impl diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index 7c458aab3f..34148aa0b2 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.changes (:require diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc index c0012dfce9..a0c733fecb 100644 --- a/common/src/app/common/files/changes_builder.cljc +++ b/common/src/app/common/files/changes_builder.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.changes-builder (:require @@ -74,6 +74,12 @@ translation? (assoc :translation? true))) +(defn set-skip-component-sync? + [changes skip-component-sync?] + (cond-> changes + skip-component-sync? + (assoc :skip-component-sync? true))) + (defn with-page [changes page] (vary-meta changes assoc diff --git a/common/src/app/common/files/comp_processors.cljc b/common/src/app/common/files/comp_processors.cljc index 9c73a2bba3..b74fd0c4b0 100644 --- a/common/src/app/common/files/comp_processors.cljc +++ b/common/src/app/common/files/comp_processors.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.comp-processors "Repair, migration or transformation utilities for components." diff --git a/common/src/app/common/files/defaults.cljc b/common/src/app/common/files/defaults.cljc index 3f4520b5a4..480271c8c3 100644 --- a/common/src/app/common/files/defaults.cljc +++ b/common/src/app/common/files/defaults.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.defaults) diff --git a/common/src/app/common/files/focus.cljc b/common/src/app/common/files/focus.cljc index b33be6e059..cb422630f0 100644 --- a/common/src/app/common/files/focus.cljc +++ b/common/src/app/common/files/focus.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.focus (:require diff --git a/common/src/app/common/files/helpers.cljc b/common/src/app/common/files/helpers.cljc index 3db1cdfba9..fcb7d6824d 100644 --- a/common/src/app/common/files/helpers.cljc +++ b/common/src/app/common/files/helpers.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.helpers (:require diff --git a/common/src/app/common/files/indices.cljc b/common/src/app/common/files/indices.cljc index 5e2f256a2e..a6e1a8a1df 100644 --- a/common/src/app/common/files/indices.cljc +++ b/common/src/app/common/files/indices.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.indices (:require diff --git a/common/src/app/common/files/migrations.cljc b/common/src/app/common/files/migrations.cljc index 28174ba84f..de8d1b8f96 100644 --- a/common/src/app/common/files/migrations.cljc +++ b/common/src/app/common/files/migrations.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.migrations (:require @@ -1978,6 +1978,26 @@ (update :pages-index d/update-vals update-container) (d/update-when :components d/update-vals update-container)))) +(defmethod migrate-data "0026-fix-svg-raw-shapes-uuids" + ;; Before the svg-raw schema declared :shapes as a vector of uuid, + ;; the JSON decoder had no type information for those child ids and + ;; left them as plain strings on any round trip, so they got + ;; persisted as strings. Once the schema was tightened, such files + ;; fail schema validation; this migration parses the strings back + ;; into uuid instances. + [data _] + (letfn [(update-object [object] + (cond-> object + (cfh/svg-raw-shape? object) + (d/update-when :shapes #(mapv uuid/coerce %)))) + + (update-container [container] + (d/update-when container :objects d/update-vals update-object))] + + (-> data + (update :pages-index d/update-vals update-container) + (d/update-when :components d/update-vals update-container)))) + (def available-migrations (into (d/ordered-set) ["legacy-2" @@ -2060,4 +2080,5 @@ "0022-normalize-component-root-and-resync" "0023-repair-token-themes-with-inexistent-sets" "0024b-fix-stroke-cap-placement" - "0025-repair-empty-text-content"])) + "0025-repair-empty-text-content" + "0026-fix-svg-raw-shapes-uuids"])) diff --git a/common/src/app/common/files/page_diff.cljc b/common/src/app/common/files/page_diff.cljc index f3535e9dae..32279b746e 100644 --- a/common/src/app/common/files/page_diff.cljc +++ b/common/src/app/common/files/page_diff.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.page-diff "Given a page in its old version and the new will retrieve a map with diff --git a/common/src/app/common/files/repair.cljc b/common/src/app/common/files/repair.cljc index 6714bfce04..0bbec120bb 100644 --- a/common/src/app/common/files/repair.cljc +++ b/common/src/app/common/files/repair.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.repair (:require @@ -10,12 +10,14 @@ [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.logging :as log] + [app.common.path-names :as cpn] [app.common.types.component :as ctk] [app.common.types.components-list :as ctkl] [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.pages-list :as ctpl] [app.common.types.shape :as cts] + [app.common.types.variant :as ctv] [app.common.uuid :as uuid])) (log/set-level! :debug) @@ -35,7 +37,7 @@ (assoc :width 0.01) (assoc :height 0.01) (cts/setup-rect)))] - (log/dbg :hint "repairing shape :invalid-geometry" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-geometry" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -48,7 +50,7 @@ (log/debug :hint " -> set to " :parent-id uuid/zero) (assoc shape :parent-id uuid/zero))] - (log/dbg :hint "repairing shape :parent-not-found" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :parent-not-found" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -61,7 +63,7 @@ (log/debug :hint " -> add children to" :parent-id (:id parent-shape)) (update parent-shape :shapes conj (:id shape)))] - (log/dbg :hint "repairing shape :child-not-in-parent" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :child-not-in-parent" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:parent-id shape)] repair-shape)))) @@ -74,7 +76,7 @@ (log/debug :hint " -> remove duplicated children") (update shape :shapes distinct))] - (log/dbg :hint "repairing shape :duplicated-children" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :duplicated-children" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -86,14 +88,14 @@ (log/debug :hint " -> remove child" :child-id (:child-id args)) (update parent-shape :shapes (fn [shapes] (d/removev #(= (:child-id args) %) shapes))))] - (log/dbg :hint "repairing shape :child-not-found" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :child-not-found" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) (defmethod repair-error :invalid-parent [_ {:keys [shape page-id args] :as error} file-data _] - (log/dbg :hint "repairing shape :invalid-parent" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-parent" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/change-parent (:parent-id args) [shape] nil {:allow-altering-copies true}))) @@ -109,7 +111,7 @@ (log/debug :hint " -> set to " :frame-id frame-id) (assoc shape :frame-id frame-id)))] - (log/dbg :hint "repairing shape :frame-not-found" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :frame-not-found" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -125,7 +127,7 @@ (log/debug :hint " -> set to " :frame-id frame-id) (assoc shape :frame-id frame-id)))] - (log/dbg :hint "repairing shape :invalid-frame" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-frame" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -138,7 +140,7 @@ (log/debug :hint " -> set :main-instance") (assoc shape :main-instance true))] - (log/dbg :hint "repairing shape :component-not-main" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :component-not-main" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -155,7 +157,7 @@ ;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") ;; shape)] - (log/dbg :hint "repairing shape :component-main-external" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :component-main-external" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -174,7 +176,7 @@ ;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") ;; shape)] - (log/dbg :hint "repairing shape :component-not-found" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :component-not-found" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes shape-ids repair-shape)))) @@ -194,7 +196,7 @@ (log/debug :hint " -> detach shape" :shape-id (:id shape)) (ctk/detach-shape shape))] - (log/dbg :hint "repairing shape :invalid-main-instance-id" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-main-instance-id" :id (:id shape) :name (:name shape) :page-id page-id) (if (and (some? component) (not (:deleted component))) (-> (pcb/empty-changes nil page-id) (pcb/with-library-data file-data) @@ -211,7 +213,7 @@ ;; Assign main instance in the component to current shape (log/debug :hint " -> assign main-instance-page" :component-id (:id component)) (assoc component :main-instance-page page-id))] - (log/dbg :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-library-data file-data) (pcb/update-component (:component-id shape) repair-component)))) @@ -224,7 +226,7 @@ (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") shape)] - (log/dbg :hint "repairing shape :invalid-main-instance" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-main-instance" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -237,7 +239,7 @@ (log/debug :hint " -> unset :main-instance") (dissoc shape :main-instance))] - (log/dbg :hint "repairing shape :component-main" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :component-main" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -250,7 +252,7 @@ (log/debug :hint " -> set :component-root") (assoc shape :component-root true))] - (log/dbg :hint "repairing shape :should-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :should-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -263,7 +265,7 @@ (log/debug :hint " -> unset :component-root") (dissoc shape :component-root))] - (log/dbg :hint "repairing shape :should-not-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :should-not-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -310,7 +312,7 @@ ;; If the shape still refers to the remote component, try to find the corresponding near one ;; and link to it. If not, detach the shape. - (log/dbg :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id) (if (some? matching-shape) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) @@ -329,7 +331,7 @@ (log/debug :hint " -> unhead shape") (ctk/unhead-shape shape))] - (log/dbg :hint "repairing shape :shape-ref-is-not-head" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :shape-ref-is-not-head" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -354,7 +356,7 @@ (nil? (:component-file args)) (dissoc :component-file)))] - (log/dbg :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -367,7 +369,7 @@ (log/debug :hint " -> reroot shape") (ctk/rehead-shape shape (:component-file args) (:component-id args)))] - (log/dbg :hint "repairing shape :shape-ref-is-head" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :shape-ref-is-head" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -396,7 +398,7 @@ (assoc acc k v))) {} objects)))))] - (log/dbg :hint "repairing component :shape-ref-cycle" :id (:id shape) :name (:name shape)) + (log/debug :hint "repairing component :shape-ref-cycle" :id (:id shape) :name (:name shape)) (-> (pcb/empty-changes nil nil) (pcb/with-library-data file-data) (pcb/update-component (:id shape) repair-component)))) @@ -409,7 +411,7 @@ (log/debug :hint " -> unset :shape-ref") (dissoc shape :shape-ref))] - (log/dbg :hint "repairing shape :shape-ref-in-main" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :shape-ref-in-main" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -422,7 +424,7 @@ (log/debug :hint " -> unset :component-root") (dissoc shape :component-root))] - (log/dbg :hint "repairing shape :root-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :root-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -435,7 +437,7 @@ (log/debug :hint " -> set :component-root") (assoc shape :component-root true))] - (log/dbg :hint "repairing shape :nested-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :nested-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape) @@ -449,7 +451,7 @@ (log/debug :hint " -> unset :component-root") (dissoc shape :component-root))] - (log/dbg :hint "repairing shape :root-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :root-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -462,7 +464,7 @@ (log/debug :hint " -> set :component-root") (assoc shape :component-root true))] - (log/dbg :hint "repairing shape :nested-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :nested-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -475,7 +477,7 @@ (log/debug :hint " -> detach shape" :shape-id (:id shape)) (ctk/detach-shape shape))] - (log/dbg :hint "repairing shape :not-head-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :not-head-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -488,7 +490,7 @@ (log/debug :hint " -> detach shape" :shape-id (:id shape)) (ctk/detach-shape shape))] - (log/dbg :hint "repairing shape :not-head-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :not-head-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -501,7 +503,7 @@ (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") shape)] - (log/dbg :hint "repairing shape :not-component-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :not-component-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -520,7 +522,7 @@ :r3 0 :r4 0))] - (log/dbg :hint "repairing shape :instance-head-not-frame" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :instance-head-not-frame" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -538,7 +540,7 @@ (log/debug :hint " -> remove :objects") (dissoc component :objects))))] - (log/dbg :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component)) + (log/debug :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component)) (-> (pcb/empty-changes nil) (pcb/with-library-data file-data) (pcb/update-component (:id component) repair-component)))) @@ -554,7 +556,7 @@ (dissoc component :objects)) component))] - (log/dbg :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component)) + (log/debug :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component)) (-> (pcb/empty-changes nil) (pcb/with-library-data file-data) (pcb/update-component (:id component) repair-component)))) @@ -567,7 +569,7 @@ (log/debug :hint " -> add :content-group to :touched-groups") (update shape :touched ctk/set-touched-group :content-group))] - (log/dbg :hint "repairing shape :invalid-text-touched" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-text-touched" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -580,7 +582,7 @@ (log/debug :hint " -> remove swap-slot") (ctk/remove-swap-slot shape))] - (log/dbg :hint "repairing shape :misplaced-slot" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :misplaced-slot" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -603,13 +605,11 @@ (log/debug :hint " -> remove swap-slot" :child-id (:id shape)) (ctk/remove-swap-slot shape))] - (log/dbg :hint "repairing shape :duplicated-slot" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :duplicated-slot" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes (map :id child-with-duplicate) repair-shape)))) - - (defmethod repair-error :component-duplicate-slot [_ {:keys [shape] :as error} file-data _] (let [main-shape (get-in shape [:objects (:main-instance-id shape)]) @@ -633,7 +633,7 @@ (:objects component))] (assoc component :objects objects)))] - (log/dbg :hint "repairing component :component-duplicated-slot" :id (:id shape) :name (:name shape)) + (log/debug :hint "repairing component :component-duplicated-slot" :id (:id shape) :name (:name shape)) (-> (pcb/empty-changes nil) (pcb/with-library-data file-data) (pcb/update-component (:id shape) repair-component)))) @@ -649,50 +649,139 @@ (ctk/set-swap-slot shape slot)) shape)))] - (log/dbg :hint "repairing shape :missing-slot" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :missing-slot" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) -(defmethod repair-error :not-a-variant - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) +(defmethod repair-error :main-instance-not-a-variant + [_ {:keys [shape page-id args]} file-data _] + (let [repair-shape + (fn [shape] + (let [variant-id (:variant-id args)] + ;; Set the desired variant-id + (log/debug :hint (str " -> set variant-id to " variant-id)) + (assoc shape :variant-id variant-id)))] -(defmethod repair-error :invalid-variant-id - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) + (log/debug :hint "repairing shape :main-instance-not-a-variant" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [(:id shape)] repair-shape)))) + +(defmethod repair-error :main-instance-invalid-variant-id + [_ {:keys [shape page-id args]} file-data _] + (let [repair-shape + (fn [shape] + (let [variant-id (:variant-id args)] + ;; Set the desired variant-id + (log/debug :hint (str " -> set variant-id to " variant-id)) + (assoc shape + :variant-id variant-id)))] + + (log/debug :hint "repairing shape :main-instance-invalid-variant-id" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [(:id shape)] repair-shape)))) (defmethod repair-error :invalid-variant-properties - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) + [_ {:keys [shape page-id args]} file-data _] + (let [prop-names (:prop-names args) + + component (get-in file-data [:components (:component-id shape)]) + prop-values (into {} (map (juxt :name :value)) (:variant-properties component)) + properties' (mapv (fn [name] {:name name :value (get prop-values name "")}) prop-names) + variant-name (ctv/properties-to-name properties') + + repair-component + (fn [component] + ;; Rebuild component properties, removing any extra ones and adding missing ones with empty value + (log/debug :hint " -> rebuild properties" :component-id (:id component) :prop-names (str prop-names)) + (assoc component :variant-properties properties')) + + repair-shape + (fn [shape] + (log/debug :hint " -> set variant-name" :variant-name variant-name) + (assoc shape :variant-name variant-name))] + + (log/debug :hint "repairing shape :invalid-variant-properties" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/with-library-data file-data) + (pcb/update-component (:component-id shape) repair-component) + (pcb/update-shapes [(:id shape)] repair-shape)))) (defmethod repair-error :variant-not-main - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) + [_ {:keys [shape page-id]} file-data _] + (let [page (ctpl/get-page file-data page-id) + shape-ids (cfh/get-children-ids-with-self (:objects page) (:id shape))] + (log/debug :hint "repairing shape :variant-not-main" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint " -> delete shapes" :shape-ids shape-ids) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/remove-objects shape-ids)))) (defmethod repair-error :parent-not-variant - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) + [_ {:keys [shape page-id]} file-data _] + (let [parent-id (:parent-id shape) + repair-fn + (fn [parent] + (log/debug :hint " -> set :is-variant-container true") + (assoc parent :is-variant-container true))] + (log/debug :hint "repairing shape :parent-not-variant" :id (:id shape) :name (:name shape) :parent-id parent-id :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [parent-id] repair-fn)))) -(defmethod repair-error :variant-bad-name - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) +(defmethod repair-error :variant-main-bad-name + [_ {:keys [shape page-id args]} file-data _] + (let [repair-fn + (fn [shape] + (log/debug :hint " -> set :name" :name (:variant-name args)) + (assoc shape :name (:variant-name args)))] + (log/debug :hint "repairing shape :variant-main-bad-name" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [(:id shape)] repair-fn)))) -(defmethod repair-error :variant-bad-variant-name - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) +(defmethod repair-error :variant-main-bad-variant-name + [_ {:keys [shape page-id]} file-data _] + (let [component (get-in file-data [:components (:component-id shape)]) + variant-name (ctv/properties-to-name (:variant-properties component)) + repair-fn + (fn [shape] + (log/debug :hint " -> set :variant-name" :variant-name variant-name) + (assoc shape :variant-name variant-name))] + (log/dbg :hint "repairing shape :variant-main-bad-variant-name" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [(:id shape)] repair-fn)))) (defmethod repair-error :variant-component-bad-name - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) + [_ {:keys [shape page-id args]} file-data _] + (let [[path name] (cpn/split-group-name (:variant-container-name args)) + repair-fn + (fn [component] + (log/debug :hint " -> set :path and :name" :path path :name name) + (assoc component :path path :name name))] + (log/dbg :hint "repairing shape :variant-component-bad-name" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-library-data file-data) + (pcb/update-component (:component-id shape) repair-fn)))) + +(defmethod repair-error :variant-component-bad-id + [_ {:keys [shape page-id args]} file-data _] + (let [repair-shape + (fn [shape] + (let [variant-id (:variant-id args)] + ;; Set the desired variant-id + (log/debug :hint (str " -> set variant-id to " variant-id)) + (assoc shape + :variant-id variant-id)))] + + (log/debug :hint "repairing shape :variant-component-bad-id" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [(:id shape)] repair-shape)))) (defmethod repair-error :default [_ error file _] @@ -701,7 +790,7 @@ (defn repair-file [{:keys [data id] :as file} libraries errors] - (log/dbg :hint "repairing file" :id (str id) :errors (count errors)) + (log/debug :hint "repairing file" :id (str id) :errors (count errors)) (let [{:keys [redo-changes]} (reduce (fn [changes error] (pcb/concat-changes changes diff --git a/common/src/app/common/files/shapes_builder.cljc b/common/src/app/common/files/shapes_builder.cljc index d8b2c2fa64..9577fb528d 100644 --- a/common/src/app/common/files/shapes_builder.cljc +++ b/common/src/app/common/files/shapes_builder.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.shapes-builder "A SVG to Shapes builder." diff --git a/common/src/app/common/files/shapes_helpers.cljc b/common/src/app/common/files/shapes_helpers.cljc index 6ecbce515e..4540501938 100644 --- a/common/src/app/common/files/shapes_helpers.cljc +++ b/common/src/app/common/files/shapes_helpers.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.shapes-helpers (:require diff --git a/common/src/app/common/files/stats.cljc b/common/src/app/common/files/stats.cljc index 99a2315243..30763ed833 100644 --- a/common/src/app/common/files/stats.cljc +++ b/common/src/app/common/files/stats.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.stats "Pure helpers that compute aggregate statistics for a file data map. diff --git a/common/src/app/common/files/tokens.cljc b/common/src/app/common/files/tokens.cljc index 95ff5da4df..89678417d0 100644 --- a/common/src/app/common/files/tokens.cljc +++ b/common/src/app/common/files/tokens.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.tokens (:require @@ -166,7 +166,6 @@ (not (ctob/token-name-path-exists? token-name tokens-tree))) new-tokens))))]]) (defn find-refs [value] - (prn value) (cond (string? value) (cto/find-token-value-references value) diff --git a/common/src/app/common/files/validate.cljc b/common/src/app/common/files/validate.cljc index 82aeed3d9e..23a7b46957 100644 --- a/common/src/app/common/files/validate.cljc +++ b/common/src/app/common/files/validate.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.validate (:require @@ -65,13 +65,13 @@ :misplaced-slot :missing-slot :shape-ref-cycle - :not-a-variant - :invalid-variant-id + :main-instance-not-a-variant + :main-instance-invalid-variant-id :invalid-variant-properties :variant-not-main :parent-not-variant - :variant-bad-name - :variant-bad-variant-name + :variant-main-bad-name + :variant-main-bad-variant-name :variant-component-bad-name :variant-component-bad-id}) @@ -573,19 +573,23 @@ (run! (fn [child-id] (when-let [child (get objects child-id)] (if (not (ctk/is-variant? child)) - (report-error :not-a-variant - (str/ffmt "Shape % should be a variant" (:id child)) - child file page) + (report-error :main-instance-not-a-variant + (str/ffmt "Main instance shape % should be a variant" (:id child)) + child file page + :variant-id shape-id) (do (when (not= (:variant-id child) shape-id) - (report-error :invalid-variant-id - (str/ffmt "Variant % has invalid variant-id %" (:id child) (:variant-id child)) - child file page)) + (report-error :main-instance-invalid-variant-id + (str/ffmt "Main instance in variant % should have the variant-id of the container but has %" (:id child) (:variant-id child)) + child file page + :variant-id shape-id)) (when (not= prop-names (cfv/extract-properties-names child file-data)) (report-error :invalid-variant-properties (str/ffmt "Variant % has invalid properties %" (:id child) (vec prop-names)) - child file page)))))) + child file page + :prop-names prop-names)))))) shapes))) + (defn- check-variant "Shape is a variant, so -it should be a main component @@ -594,9 +598,9 @@ -its name should be the same as its parent's " [shape file page] - (let [parent (ctst/get-shape page (:parent-id shape)) - component (ctkl/get-component (:data file) (:component-id shape) true) - name (ctv/properties-to-name (:variant-properties component))] + (let [parent (ctst/get-shape page (:parent-id shape)) + component (ctkl/get-component (:data file) (:component-id shape) true) + variant-name (ctv/properties-to-name (:variant-properties component))] (when-not (ctk/main-instance? shape) (report-error :variant-not-main (str/ffmt "Variant % is not a main instance" (:id shape)) @@ -605,23 +609,26 @@ (report-error :parent-not-variant (str/ffmt "Variant % has an invalid parent" (:id shape)) shape file page)) - - (when-not (= name (:variant-name shape)) - (report-error :variant-bad-variant-name + (when-not (= variant-name (:variant-name shape)) + (report-error :variant-main-bad-variant-name (str/ffmt "Variant % has an invalid variant-name" (:id shape)) - shape file page)) + shape file page + :variant-name variant-name)) (when-not (= (:name parent) (:name shape)) - (report-error :variant-bad-name - (str/ffmt "Variant % has an invalid name" (:id shape)) - shape file page)) + (report-error :variant-main-bad-name + (str/ffmt "Main instance inside variant % has an invalid name" (:id shape)) + shape file page + :variant-name (:name parent))) (when-not (= (:name parent) (cpn/merge-path-item (:path component) (:name component))) (report-error :variant-component-bad-name (str/ffmt "Component % has an invalid name" (:id shape)) - shape file page)) + shape file page + :variant-container-name (:name parent))) (when-not (= (:variant-id component) (:variant-id shape)) (report-error :variant-component-bad-id (str/ffmt "Variant % has adifferent variant-id than its component" (:id shape)) - shape file page)))) + shape file page + :variant-id (:variant-id component))))) (defn- check-shape "Validate referential integrity and semantic coherence of @@ -740,14 +747,15 @@ -It should have at least one variant property" [component file] (let [component-page (ctf/get-component-page (:data file) component) - main-component (if (:deleted component) + main-instance (if (:deleted component) (dm/get-in component [:objects (:main-instance-id component)]) (ctst/get-shape component-page (:main-instance-id component)))] - (when (and main-component - (not (ctk/is-variant? main-component))) - (report-error :not-a-variant - (str/ffmt "Shape % should be a variant" (:id main-component)) - main-component file component-page)))) + (when (and main-instance + (not (ctk/is-variant? main-instance))) + (report-error :main-instance-not-a-variant + (str/ffmt "Main instance shape % should be a variant" (:id main-instance)) + main-instance file component-page + :variant-id (:variant-id component))))) (defn- check-main-inside-main [component file] diff --git a/common/src/app/common/files/variant.cljc b/common/src/app/common/files/variant.cljc index 649e1d743c..989a80a58b 100644 --- a/common/src/app/common/files/variant.cljc +++ b/common/src/app/common/files/variant.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.variant (:require [app.common.data.macros :as dm] diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index ddfa667165..7ef1c0c6df 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.flags "Flags parsing algorithm." @@ -100,6 +100,10 @@ :backend-svgo ;; If enabled, it makes the Google Fonts available. :google-fonts-provider + ;; Enables the Ladybug graph subsystem: the `/dbg` graph console and its + ;; actions. Off by default. With the flag off, `app.graph.*` never loads, + ;; so the Ladybug native library never enters the JVM. + :graph ;; Only for development. :nrepl-server ;; Interactive repl. Only for development. @@ -147,7 +151,6 @@ :render-switch :hide-release-modal :subscriptions - :subscriptions-old :inspect-styles ;; Enable performance logs in devconsole (disabled by default) :perf-logs @@ -178,7 +181,11 @@ :stroke-path :stroke-per-side - :custom-shortcuts}) + ;; Exporter only: uses render-wasm for export instead of browser + ;; renderer. + :wasm-export + :custom-shortcuts + :remote-media-processing}) (def all-flags (set/union email login varia)) diff --git a/frontend/src/app/main/fonts.clj b/common/src/app/common/fonts.clj similarity index 95% rename from frontend/src/app/main/fonts.clj rename to common/src/app/common/fonts.clj index c8abbd0478..8580abb8dd 100644 --- a/frontend/src/app/main/fonts.clj +++ b/common/src/app/common/fonts.clj @@ -2,10 +2,11 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL -(ns app.main.fonts +(ns app.common.fonts "A fonts loading macros." + (:require [app.common.uuid :as uuid] [clojure.data.json :as json] @@ -47,6 +48,3 @@ (let [data (slurp (io/resource path)) data (json/read-str data)] `~(mapv parse-gfont (get data "items")))) - - - diff --git a/frontend/src/app/render_wasm/fallback_fonts.cljs b/common/src/app/common/fonts.cljs similarity index 60% rename from frontend/src/app/render_wasm/fallback_fonts.cljs rename to common/src/app/common/fonts.cljs index 80f52be51c..237a6b362b 100644 --- a/frontend/src/app/render_wasm/fallback_fonts.cljs +++ b/common/src/app/common/fonts.cljs @@ -2,15 +2,161 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL -(ns app.render-wasm.fallback-fonts - "Host-agnostic fallback-font knowledge: which scripts/emoji a text uses and - which (google) fallback fonts cover them. Pure data + pure fns — no browser - or Node dependencies — so the workspace (`api.texts`/`api.fonts`) and the - headless exporter (`app.renderer.wasm`) compute the SAME fallback set from - the same source. Anything a host must fetch/upload for text to render - belongs here, not in host code.") +(ns app.common.fonts + "Host-agnostic font knowledge shared by every renderer: the google catalog + baked at compile time from `common/resources/fonts/gfonts.*.json`, the + font-id/uuid mapping, weight/style variant resolution, and the noto fallback + fonts a text's scripts and emoji need. Also the one family bundled with the + frontend, which is not a google font but resolves by the same rules. + + Pure data + pure fns — no browser or Node dependencies — so the workspace and + the headless exporter resolve the SAME fonts from the same source. Anything a + host must fetch or upload for text to render belongs here, not in host code." + (:require-macros [app.common.fonts :refer [preload-gfonts]]) + (:require + [app.common.data :as d] + [app.common.uuid :as uuid] + [cuerdas.core :as str])) + +;; --- GOOGLE FONTS CATALOG + +(def catalog + (preload-gfonts "fonts/gfonts.2025.11.28.json")) + +(def ^:private by-id + (reduce (fn [m font] (assoc m (:id font) font)) {} catalog)) + +(def ^:private by-uuid + (reduce (fn [m font] (assoc m (:uuid font) font)) {} catalog)) + +(defn gfont-id->uuid + "Maps a `gfont-<slug>` id to its (compilation-stable) catalog uuid, or nil." + [gfont-id] + (:uuid (get by-id gfont-id))) + +;; --- font-id -> wasm uuid + +(def ^:private custom-prefix "custom-") +(def ^:private gfont-prefix "gfont-") + +(defn font-id->backend + "Which source a content font-id comes from: `:google` for `gfont-<slug>`, + `:custom` for `custom-<uuid>`, `:builtin` for everything else (bundled + families, but also unknown or malformed ids — the same bucket + `font-id->uuid` maps to `uuid/zero`)." + [font-id] + (cond + (not (string? font-id)) :builtin + (str/starts-with? font-id gfont-prefix) :google + (str/starts-with? font-id custom-prefix) :custom + :else :builtin)) + +(defn font-id->uuid + "Maps a content font-id to the uuid WASM keys fonts by: + + - `gfont-<slug>` -> the catalog uuid, + - `custom-<uuid>` -> that uuid, + - anything else (builtin, unknown, malformed) -> `uuid/zero`, which WASM + resolves to the default font." + + [font-id] + (case (font-id->backend font-id) + :google (or (gfont-id->uuid font-id) uuid/zero) + :custom (or (uuid/parse* (subs font-id (count custom-prefix))) uuid/zero) + uuid/zero)) + +;; --- proxy urls + +(def ^:private gstatic-prefix + "https://fonts.gstatic.com/s") + +(defn gstatic->proxy-url + [s base] + (let [base (str/rtrim (str base) "/")] + (str/replace (str s) gstatic-prefix base))) + +;; --- variant resolution + +(defn closest-variant + [variants target-weight target-style] + (when-let [target-weight (d/parse-integer target-weight)] + (let [result + (reduce + (fn [closest-match variant] + (let [weight (d/parse-integer (:weight variant)) + distance (abs (- target-weight weight)) + matches-style? (= target-style (:style variant)) + current {:variant variant + :weight weight + :distance distance}] + (cond + ;; Exact match found + (and (zero? distance) + (if target-style matches-style? true)) + (reduced current) + + (nil? closest-match) current + + ;; Update best match if this variant is closer or equal distance but higher weight + (or (< distance (:distance closest-match)) + (and (= distance (:distance closest-match)) + (> weight (:weight closest-match)))) + current + + ;; Same weight as the `closest-match` but the style matches `target-style` + (and (= weight (:weight closest-match)) matches-style?) + current + + :else + closest-match))) + nil + variants)] + (:variant result)))) + +(defn resolve-ttf-url + [font-uuid weight style] + (when-let [font (get by-uuid font-uuid)] + (let [style (if (zero? style) "normal" "italic") + variants (:variants font)] + (:ttf-url (or (closest-variant variants weight style) + (first variants)))))) + +;; --- BUILTIN FONTS +;; +;; Bundled with the frontend, served from `<public-uri>/fonts/`. Shared so the +;; workspace and the exporter upload the same TTF for a given weight/style. + +(def local-fonts + [{:id "sourcesanspro" + :name "Source Sans Pro" + :family "sourcesanspro" + :variants + [{:id "200" :name "200" :weight "200" :style "normal" :suffix "extralight" :ttf-url "sourcesanspro-extralight.ttf"} + {:id "200italic" :name "200 Italic" :weight "200" :style "italic" :suffix "extralightitalic" :ttf-url "sourcesanspro-extralightitalic.ttf"} + {:id "300" :name "300" :weight "300" :style "normal" :suffix "light" :ttf-url "sourcesanspro-light.ttf"} + {:id "300italic" :name "300 Italic" :weight "300" :style "italic" :suffix "lightitalic" :ttf-url "sourcesanspro-lightitalic.ttf"} + {:id "regular" :name "400" :weight "400" :style "normal" :ttf-url "sourcesanspro-regular.ttf"} + {:id "italic" :name "400 Italic" :weight "400" :style "italic" :ttf-url "sourcesanspro-italic.ttf"} + {:id "600" :name "600" :weight "600" :style "normal" :suffix "semibold" :ttf-url "sourcesanspro-semibold.ttf"} + {:id "600italic" :name "600 Italic" :weight "600" :style "italic" :suffix "semibolditalic" :ttf-url "sourcesanspro-semibolditalic.ttf"} + {:id "bold" :name "700" :weight "700" :style "normal" :ttf-url "sourcesanspro-bold.ttf"} + {:id "bolditalic" :name "700 Italic" :weight "700" :style "italic" :ttf-url "sourcesanspro-bolditalic.ttf"} + {:id "black" :name "900" :weight "900" :style "normal" :ttf-url "sourcesanspro-black.ttf"} + {:id "blackitalic" :name "900 Italic" :weight "900" :style "italic" :ttf-url "sourcesanspro-blackitalic.ttf"}]}]) + +(defn resolve-ttf-file + "Builtin TTF file name for `weight` and `style` (0 normal, 1 italic), by the + same nearest-weight rule as the google catalog." + [weight style] + (let [variants (:variants (first local-fonts))] + (:ttf-url (or (closest-variant variants weight (if (zero? style) "normal" "italic")) + (first variants))))) + +;; --- FALLBACK FONTS +;; +;; Which scripts/emoji a text uses and which (google) fallback fonts cover them. (def ^:private emoji-pattern #"(?:\uD83C[\uDDE6-\uDDFF]\uD83C[\uDDE6-\uDDFF])|(?:\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDEFF])|(?:\uD83E[\uDD00-\uDDFF])|(?:\uD83D[\uDE80-\uDEFF]|\uD83E[\uDC00-\uDCFF])|(?:\uD83E[\uDE70-\uDFFF])|[\u2600-\u26FF\u2700-\u27BF\u2300-\u23FF\u2B00-\u2BFF]") diff --git a/common/src/app/common/fressian.clj b/common/src/app/common/fressian.clj index b16d233b42..3d0f41eb2f 100644 --- a/common/src/app/common/fressian.clj +++ b/common/src/app/common/fressian.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.fressian (:require @@ -31,6 +31,11 @@ ([^String s, ^String encoding] (.getBytes s encoding))) +;; --- DEPTH TRACKING + +(def ^:dynamic *read-depth* 0) +(def ^:const max-read-depth 128) + ;; --- LOW LEVEL FRESSIAN API (defn write-object! @@ -41,7 +46,13 @@ (defn read-object! [^Reader r] - (.readObject r)) + (when (>= *read-depth* max-read-depth) + (throw (ex-info "maximum Fressian read depth exceeded" + {:type :validation + :code :max-read-depth-reached + :hint "maximum Fressian read depth exceeded"}))) + (binding [*read-depth* (inc *read-depth*)] + (.readObject r))) (defn write-tag! ([^Writer w ^String n] diff --git a/common/src/app/common/generic_pool.clj b/common/src/app/common/generic_pool.clj index 8a97667713..21481367a2 100644 --- a/common/src/app/common/generic_pool.clj +++ b/common/src/app/common/generic_pool.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.generic-pool (:refer-clojure :exclude [get]) diff --git a/common/src/app/common/geom/align.cljc b/common/src/app/common/geom/align.cljc index 759972e8bf..959cf18018 100644 --- a/common/src/app/common/geom/align.cljc +++ b/common/src/app/common/geom/align.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.align (:require diff --git a/common/src/app/common/geom/bounds_map.cljc b/common/src/app/common/geom/bounds_map.cljc index f9cffe73ab..7a6b272e61 100644 --- a/common/src/app/common/geom/bounds_map.cljc +++ b/common/src/app/common/geom/bounds_map.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.bounds-map (:require diff --git a/common/src/app/common/geom/grid.cljc b/common/src/app/common/geom/grid.cljc index 298824af61..209869bb06 100644 --- a/common/src/app/common/geom/grid.cljc +++ b/common/src/app/common/geom/grid.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.grid (:require diff --git a/common/src/app/common/geom/line.cljc b/common/src/app/common/geom/line.cljc index a20a56b082..55f5151a00 100644 --- a/common/src/app/common/geom/line.cljc +++ b/common/src/app/common/geom/line.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.line) diff --git a/common/src/app/common/geom/matrix.cljc b/common/src/app/common/geom/matrix.cljc index 3b5612bdd5..9e7239f01d 100644 --- a/common/src/app/common/geom/matrix.cljc +++ b/common/src/app/common/geom/matrix.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.matrix (:require diff --git a/common/src/app/common/geom/modif_tree.cljc b/common/src/app/common/geom/modif_tree.cljc index c222d5f89b..1741588889 100644 --- a/common/src/app/common/geom/modif_tree.cljc +++ b/common/src/app/common/geom/modif_tree.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.modif-tree (:require diff --git a/common/src/app/common/geom/modifiers.cljc b/common/src/app/common/geom/modifiers.cljc index 946bcff269..4114ce37e0 100644 --- a/common/src/app/common/geom/modifiers.cljc +++ b/common/src/app/common/geom/modifiers.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.modifiers (:require diff --git a/common/src/app/common/geom/point.cljc b/common/src/app/common/geom/point.cljc index f281488660..56123e7a39 100644 --- a/common/src/app/common/geom/point.cljc +++ b/common/src/app/common/geom/point.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.point (:refer-clojure :exclude [divide min max abs zero?]) diff --git a/common/src/app/common/geom/proportions.cljc b/common/src/app/common/geom/proportions.cljc index cc6fe27b79..2245af2f0f 100644 --- a/common/src/app/common/geom/proportions.cljc +++ b/common/src/app/common/geom/proportions.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.proportions (:require diff --git a/common/src/app/common/geom/rect.cljc b/common/src/app/common/geom/rect.cljc index e9e5e79c9d..3699ae5bd3 100644 --- a/common/src/app/common/geom/rect.cljc +++ b/common/src/app/common/geom/rect.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.rect (:require diff --git a/common/src/app/common/geom/shapes.cljc b/common/src/app/common/geom/shapes.cljc index 66caaa9aed..4f0ac90fe9 100644 --- a/common/src/app/common/geom/shapes.cljc +++ b/common/src/app/common/geom/shapes.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes (:require diff --git a/common/src/app/common/geom/shapes/bounds.cljc b/common/src/app/common/geom/shapes/bounds.cljc index 794d578c7d..489455a368 100644 --- a/common/src/app/common/geom/shapes/bounds.cljc +++ b/common/src/app/common/geom/shapes/bounds.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.bounds (:require diff --git a/common/src/app/common/geom/shapes/common.cljc b/common/src/app/common/geom/shapes/common.cljc index 2f0017b344..d344a5d09f 100644 --- a/common/src/app/common/geom/shapes/common.cljc +++ b/common/src/app/common/geom/shapes/common.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.common (:require diff --git a/common/src/app/common/geom/shapes/constraints.cljc b/common/src/app/common/geom/shapes/constraints.cljc index 954c426306..ca92c93cc7 100644 --- a/common/src/app/common/geom/shapes/constraints.cljc +++ b/common/src/app/common/geom/shapes/constraints.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.constraints (:require diff --git a/common/src/app/common/geom/shapes/corners.cljc b/common/src/app/common/geom/shapes/corners.cljc index d43df249c9..32e63c3d4e 100644 --- a/common/src/app/common/geom/shapes/corners.cljc +++ b/common/src/app/common/geom/shapes/corners.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.corners (:require diff --git a/common/src/app/common/geom/shapes/effects.cljc b/common/src/app/common/geom/shapes/effects.cljc index 7e1096e6b8..f03875a398 100644 --- a/common/src/app/common/geom/shapes/effects.cljc +++ b/common/src/app/common/geom/shapes/effects.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.effects) diff --git a/common/src/app/common/geom/shapes/fit_frame.cljc b/common/src/app/common/geom/shapes/fit_frame.cljc index c93e663496..65d529c420 100644 --- a/common/src/app/common/geom/shapes/fit_frame.cljc +++ b/common/src/app/common/geom/shapes/fit_frame.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.fit-frame (:require diff --git a/common/src/app/common/geom/shapes/flex_layout.cljc b/common/src/app/common/geom/shapes/flex_layout.cljc index 509dce1ba5..e2ed76c5ca 100644 --- a/common/src/app/common/geom/shapes/flex_layout.cljc +++ b/common/src/app/common/geom/shapes/flex_layout.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.flex-layout (:require diff --git a/common/src/app/common/geom/shapes/flex_layout/bounds.cljc b/common/src/app/common/geom/shapes/flex_layout/bounds.cljc index b434d99fc9..4f4cc1895c 100644 --- a/common/src/app/common/geom/shapes/flex_layout/bounds.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/bounds.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.flex-layout.bounds (:require diff --git a/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc b/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc index 7f71da31d6..33553d671f 100644 --- a/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.flex-layout.drop-area (:require diff --git a/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc b/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc index 4144fa7119..dfdd8cd094 100644 --- a/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.flex-layout.layout-data (:require diff --git a/common/src/app/common/geom/shapes/flex_layout/modifiers.cljc b/common/src/app/common/geom/shapes/flex_layout/modifiers.cljc index 5d27cbb69a..9aaadea470 100644 --- a/common/src/app/common/geom/shapes/flex_layout/modifiers.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/modifiers.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.flex-layout.modifiers (:require diff --git a/common/src/app/common/geom/shapes/flex_layout/params.cljc b/common/src/app/common/geom/shapes/flex_layout/params.cljc index ffd75a2e6f..57a1fab63d 100644 --- a/common/src/app/common/geom/shapes/flex_layout/params.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/params.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.flex-layout.params (:require diff --git a/common/src/app/common/geom/shapes/flex_layout/positions.cljc b/common/src/app/common/geom/shapes/flex_layout/positions.cljc index 6251650055..2dcb28d42d 100644 --- a/common/src/app/common/geom/shapes/flex_layout/positions.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/positions.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.flex-layout.positions (:require diff --git a/common/src/app/common/geom/shapes/grid_layout.cljc b/common/src/app/common/geom/shapes/grid_layout.cljc index e81de6711c..4ac7b85284 100644 --- a/common/src/app/common/geom/shapes/grid_layout.cljc +++ b/common/src/app/common/geom/shapes/grid_layout.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.grid-layout (:require diff --git a/common/src/app/common/geom/shapes/grid_layout/areas.cljc b/common/src/app/common/geom/shapes/grid_layout/areas.cljc index 01b56901c6..31837c5692 100644 --- a/common/src/app/common/geom/shapes/grid_layout/areas.cljc +++ b/common/src/app/common/geom/shapes/grid_layout/areas.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL ;; Based on the code in: ;; https://en.wikibooks.org/wiki/Algorithm_Implementation/Geometry/Rectangle_difference diff --git a/common/src/app/common/geom/shapes/grid_layout/bounds.cljc b/common/src/app/common/geom/shapes/grid_layout/bounds.cljc index caadff4766..f92e365db6 100644 --- a/common/src/app/common/geom/shapes/grid_layout/bounds.cljc +++ b/common/src/app/common/geom/shapes/grid_layout/bounds.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.grid-layout.bounds (:require diff --git a/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc b/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc index 93322435c9..756eb2924e 100644 --- a/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc +++ b/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL ;; Each track has specified minimum and maximum sizing functions (which may be the same) ;; - Fixed diff --git a/common/src/app/common/geom/shapes/grid_layout/params.cljc b/common/src/app/common/geom/shapes/grid_layout/params.cljc index 6cc1a2f36c..ee3a779942 100644 --- a/common/src/app/common/geom/shapes/grid_layout/params.cljc +++ b/common/src/app/common/geom/shapes/grid_layout/params.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.grid-layout.params (:require diff --git a/common/src/app/common/geom/shapes/grid_layout/positions.cljc b/common/src/app/common/geom/shapes/grid_layout/positions.cljc index 3144e68f82..3b818a56d2 100644 --- a/common/src/app/common/geom/shapes/grid_layout/positions.cljc +++ b/common/src/app/common/geom/shapes/grid_layout/positions.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.grid-layout.positions (:require diff --git a/common/src/app/common/geom/shapes/intersect.cljc b/common/src/app/common/geom/shapes/intersect.cljc index 9338a0ad56..f63fc7f740 100644 --- a/common/src/app/common/geom/shapes/intersect.cljc +++ b/common/src/app/common/geom/shapes/intersect.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.intersect (:require diff --git a/common/src/app/common/geom/shapes/min_size_layout.cljc b/common/src/app/common/geom/shapes/min_size_layout.cljc index 57375098e9..49617ce6ea 100644 --- a/common/src/app/common/geom/shapes/min_size_layout.cljc +++ b/common/src/app/common/geom/shapes/min_size_layout.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.min-size-layout (:require diff --git a/common/src/app/common/geom/shapes/pixel_precision.cljc b/common/src/app/common/geom/shapes/pixel_precision.cljc index 30fdf89f50..82210de222 100644 --- a/common/src/app/common/geom/shapes/pixel_precision.cljc +++ b/common/src/app/common/geom/shapes/pixel_precision.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.pixel-precision (:require diff --git a/common/src/app/common/geom/shapes/points.cljc b/common/src/app/common/geom/shapes/points.cljc index a1646ee44f..1ce8eaec76 100644 --- a/common/src/app/common/geom/shapes/points.cljc +++ b/common/src/app/common/geom/shapes/points.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.points (:require diff --git a/common/src/app/common/geom/shapes/rect.cljc b/common/src/app/common/geom/shapes/rect.cljc index 7a93ab8acb..951a0869d6 100644 --- a/common/src/app/common/geom/shapes/rect.cljc +++ b/common/src/app/common/geom/shapes/rect.cljc @@ -2,6 +2,6 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.rect) diff --git a/common/src/app/common/geom/shapes/strokes.cljc b/common/src/app/common/geom/shapes/strokes.cljc index d5876995ff..567c6a9417 100644 --- a/common/src/app/common/geom/shapes/strokes.cljc +++ b/common/src/app/common/geom/shapes/strokes.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.strokes) diff --git a/common/src/app/common/geom/shapes/text.cljc b/common/src/app/common/geom/shapes/text.cljc index 35e2359ef1..1507621e6d 100644 --- a/common/src/app/common/geom/shapes/text.cljc +++ b/common/src/app/common/geom/shapes/text.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.text (:require diff --git a/common/src/app/common/geom/shapes/transforms.cljc b/common/src/app/common/geom/shapes/transforms.cljc index d0ff71a609..7ba56f2197 100644 --- a/common/src/app/common/geom/shapes/transforms.cljc +++ b/common/src/app/common/geom/shapes/transforms.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.transforms (:require diff --git a/common/src/app/common/geom/shapes/tree_seq.cljc b/common/src/app/common/geom/shapes/tree_seq.cljc index 17da34e8f8..2c069815f3 100644 --- a/common/src/app/common/geom/shapes/tree_seq.cljc +++ b/common/src/app/common/geom/shapes/tree_seq.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.tree-seq (:require diff --git a/common/src/app/common/geom/snap.cljc b/common/src/app/common/geom/snap.cljc index 2b3db1bda6..979b9cfad6 100644 --- a/common/src/app/common/geom/snap.cljc +++ b/common/src/app/common/geom/snap.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.snap (:require diff --git a/common/src/app/common/i18n.cljc b/common/src/app/common/i18n.cljc index 8aa8a61a0f..a542870813 100644 --- a/common/src/app/common/i18n.cljc +++ b/common/src/app/common/i18n.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.i18n "Dummy i18n functions, to be used by code in common that needs translations.") diff --git a/common/src/app/common/json.cljc b/common/src/app/common/json.cljc index 0861c33c1a..079f14073d 100644 --- a/common/src/app/common/json.cljc +++ b/common/src/app/common/json.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.json (:refer-clojure :exclude [read clj->js js->clj]) diff --git a/common/src/app/common/logging.cljc b/common/src/app/common/logging.cljc index ffed8cc09f..42e4f84566 100644 --- a/common/src/app/common/logging.cljc +++ b/common/src/app/common/logging.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.logging "A lightweight and multiplaform (clj & cljs) asynchronous by default diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index a89aa633ab..b8b1274478 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.logic.libraries #?(:cljs (:require-macros [app.common.logic.libraries :refer [shape-log container-log]])) @@ -2345,7 +2345,12 @@ updated-sync-groups (into #{} (keep #(ctk/resolve-sync-group (:type previous-shape) %)) updated-attrs) - new-touched (set/union (or (:touched current-shape) #{}) updated-sync-groups) + text-sub-touched #{:text-content-text :text-content-attribute :text-content-structure} + new-touched (set/union (or (:touched current-shape) #{}) + updated-sync-groups + (when (contains? updated-sync-groups :content-group) + (set/intersection (or (:touched previous-shape) #{}) + text-sub-touched))) roperations (into [{:type :set-touched :touched new-touched}] roperations) uoperations (into (list {:type :set-touched :touched (:touched current-shape)}) uoperations)] (cond-> changes diff --git a/common/src/app/common/logic/shapes.cljc b/common/src/app/common/logic/shapes.cljc index 831fce4076..638b5abd7c 100644 --- a/common/src/app/common/logic/shapes.cljc +++ b/common/src/app/common/logic/shapes.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.logic.shapes (:require diff --git a/common/src/app/common/logic/tokens.cljc b/common/src/app/common/logic/tokens.cljc index ffec485d54..493d23d1f4 100644 --- a/common/src/app/common/logic/tokens.cljc +++ b/common/src/app/common/logic/tokens.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.logic.tokens (:require diff --git a/common/src/app/common/logic/variant_properties.cljc b/common/src/app/common/logic/variant_properties.cljc index 9a39b82400..fe87fa3e5e 100644 --- a/common/src/app/common/logic/variant_properties.cljc +++ b/common/src/app/common/logic/variant_properties.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.logic.variant-properties (:require [app.common.data :as d] diff --git a/common/src/app/common/math.cljc b/common/src/app/common/math.cljc index 839079efee..41e3047880 100644 --- a/common/src/app/common/math.cljc +++ b/common/src/app/common/math.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.math "A collection of math utils." diff --git a/common/src/app/common/media.cljc b/common/src/app/common/media.cljc index 3507ba5f59..8e6038ea11 100644 --- a/common/src/app/common/media.cljc +++ b/common/src/app/common/media.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.media "Media assets helpers (images, fonts, etc)" @@ -22,6 +22,9 @@ "image/gif" "image/svg+xml"}) +(def tempfile-types + (conj image-types "application/pdf" "application/zip")) + (defn format->extension [format] (case format diff --git a/common/src/app/common/path_names.cljc b/common/src/app/common/path_names.cljc index 90da658f17..de96093155 100644 --- a/common/src/app/common/path_names.cljc +++ b/common/src/app/common/path_names.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.path-names (:require diff --git a/common/src/app/common/perf.cljc b/common/src/app/common/perf.cljc index 009db14237..16a82126c1 100644 --- a/common/src/app/common/perf.cljc +++ b/common/src/app/common/perf.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.perf (:require diff --git a/common/src/app/common/pprint.cljc b/common/src/app/common/pprint.cljc index 386054ed49..5154fda27d 100644 --- a/common/src/app/common/pprint.cljc +++ b/common/src/app/common/pprint.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.pprint (:refer-clojure :exclude [prn]) diff --git a/common/src/app/common/record.cljc b/common/src/app/common/record.cljc index ee3b191baa..0a7b4a7f97 100644 --- a/common/src/app/common/record.cljc +++ b/common/src/app/common/record.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.record "A collection of helpers and macros for defien a penpot customized record types." diff --git a/common/src/app/common/render_wasm/README.md b/common/src/app/common/render_wasm/README.md new file mode 100644 index 0000000000..b153832e52 --- /dev/null +++ b/common/src/app/common/render_wasm/README.md @@ -0,0 +1,24 @@ +# `app.common.render-wasm.*` + +The host-agnostic ClojureScript side of the render-wasm binary protocol: byte +layouts, memory helpers and serializers that turn Penpot shapes into the buffers +`render-wasm` consumes. + +The workspace drives it from `app.render-wasm.*`, the headless exporter from +`app.wasm.*` — same code underneath, so the two cannot drift. + +Font knowledge is *not* here even though both hosts need it for rendering: it is +not specific to the wasm backend, so the google fonts catalog (baked from +`common/resources/fonts/gfonts.*.json`), the bundled builtin family and the +emoji/script fallback tables live in `app.common.fonts`. Likewise the image-id +enumeration lives in `app.common.types.shape.images`. + +`shared.js` is not here: it is a per-build artifact, so each host compiles +against the copy from its own render-wasm build and passes it to +`wasm/init-serializers!` (see `app.render-wasm.api.enums`, `app.wasm.enums`). + +## Rules for anything added here + +**Nothing here may depend on a browser (no DOM, no WebGL, no app state) or on +`frontend/src`.** Dependencies are `app.common.*` and this subtree only. It also +has to run under plain Node — a `js/document` here breaks the exporter. diff --git a/frontend/src/app/render_wasm/api/props.cljs b/common/src/app/common/render_wasm/api/props.cljs similarity index 89% rename from frontend/src/app/render_wasm/api/props.cljs rename to common/src/app/common/render_wasm/api/props.cljs index 26bbdadc3a..816c154cab 100644 --- a/frontend/src/app/render_wasm/api/props.cljs +++ b/common/src/app/common/render_wasm/api/props.cljs @@ -2,9 +2,9 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL -(ns app.render-wasm.api.props +(ns app.common.render-wasm.api.props "Browser-free WASM shape property setters, shared by the workspace render orchestrator (`app.render-wasm.api`) and the headless exporter (`app.wasm.serialize`). @@ -15,34 +15,17 @@ data sources (fonts, image bytes, SVG static markup) stay in `app.render-wasm.api`." (:require [app.common.math :as mth] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.serializers.color :as sr-clr] + [app.common.render-wasm.wasm :as wasm] [app.common.types.fills :as types.fills] [app.common.types.fills.impl :as types.fills.impl] - [app.common.types.path :as path] - [app.render-wasm.helpers :as h] - [app.render-wasm.mem :as mem] - [app.render-wasm.mem.heap32 :as mem.h32] - [app.render-wasm.serializers :as sr] - [app.render-wasm.serializers.color :as sr-clr] - [app.render-wasm.wasm :as wasm])) + [app.common.types.path :as path])) (def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024)) -(def ^:const UUID-U8-SIZE 16) - -(defn set-shape-children - "Uploads the child id list via the dynamic `_set_children` path (handles any - count). The browser also has fixed-arity fast paths for the incremental edit - path; this dynamic one is the shared/batch version." - [children] - (let [children (into [] (filter uuid?) children)] - (if (empty? children) - (h/call wasm/internal-module "_set_children_0") - (let [heap (mem/get-heap-u32) - size (mem/get-alloc-size children UUID-U8-SIZE) - offset (mem/alloc->offset-32 size)] - (reduce (fn [o id] (mem.h32/write-uuid o heap id)) offset children) - (h/call wasm/internal-module "_set_children"))))) - (defn set-shape-bool-type [bool-type] (h/call wasm/internal-module "_set_shape_bool_type" (sr/translate-bool-type bool-type))) diff --git a/common/src/app/common/render_wasm/api/upload.cljs b/common/src/app/common/render_wasm/api/upload.cljs new file mode 100644 index 0000000000..eb2e42f57c --- /dev/null +++ b/common/src/app/common/render_wasm/api/upload.cljs @@ -0,0 +1,453 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.common.render-wasm.api.upload + "Enlarged per-shape + multi-shape structural upload for WASM cold load. + + Writes a binary batch consumed by `_set_shapes_batch`. Remaining + host-specific attrs (image bytes, text, path, grid tracks) are applied + afterwards via the existing per-shape setters." + (:require + [app.common.buffer :as buf] + [app.common.data :as d] + [app.common.data.macros :as dm] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.serializers.color :as sr-clr] + [app.common.render-wasm.wasm :as wasm] + [app.common.types.fills :as types.fills] + [app.common.types.fills.impl :as types.fills.impl] + [app.common.types.shape.layout :as ctl] + [app.common.uuid :as uuid])) + +(def ^:const BASE-PROPS-SIZE 104) +(def ^:const FLAG-CLIP-CONTENT 0x01) +(def ^:const FLAG-HIDDEN 0x02) + +(def ^:const SECTION-CHILDREN 0x01) +(def ^:const SECTION-BLUR-LAYER 0x02) +(def ^:const SECTION-BLUR-BG 0x04) +(def ^:const SECTION-SHADOWS 0x08) +(def ^:const SECTION-MASKED 0x10) +(def ^:const SECTION-BOOL-TYPE 0x20) +(def ^:const SECTION-GROW-TYPE 0x40) +(def ^:const SECTION-LAYOUT-ITEM 0x80) +(def ^:const SECTION-FLEX 0x100) +(def ^:const SECTION-FILLS 0x200) +(def ^:const SECTION-STROKES 0x400) + +;; Stroke header before RawFillData (must match upload_batch.rs). +(def ^:const STROKE-HEADER-U8-SIZE 36) +(def ^:const STROKE-ALIGN-CENTER 0) +(def ^:const STROKE-ALIGN-INNER 1) +(def ^:const STROKE-ALIGN-OUTER 2) + +(defn- write-uuid! + [dview offset id] + (buf/write-uuid dview offset id) + (+ offset 16)) + +(defn- write-base-props! + "Write the 104-byte RawBasePropsData at `offset`. Returns next offset." + [dview offset shape] + (let [id (dm/get-prop shape :id) + parent-id (get shape :parent-id) + shape-type (dm/get-prop shape :type) + clip-content (if (= shape-type :frame) + (not (get shape :show-content)) + false) + hidden (get shape :hidden false) + flags (cond-> 0 + clip-content (bit-or FLAG-CLIP-CONTENT) + hidden (bit-or FLAG-HIDDEN)) + blend-mode (sr/translate-blend-mode (get shape :blend-mode)) + constraint-h (sr/translate-constraint-h (or (get shape :constraints-h) :none)) + constraint-v (sr/translate-constraint-v (or (get shape :constraints-v) :none)) + opacity (d/nilv (get shape :opacity) 1.0) + rotation (d/nilv (get shape :rotation) 0.0) + transform (get shape :transform) + [ta tb tc td te tf] + (if (some? transform) + [(dm/get-prop transform :a) + (dm/get-prop transform :b) + (dm/get-prop transform :c) + (dm/get-prop transform :d) + (dm/get-prop transform :e) + (dm/get-prop transform :f)] + [1.0 0.0 0.0 1.0 0.0 0.0]) + selrect (get shape :selrect) + [sx1 sy1 sx2 sy2] + (if (some? selrect) + [(dm/get-prop selrect :x1) + (dm/get-prop selrect :y1) + (dm/get-prop selrect :x2) + (dm/get-prop selrect :y2)] + [0.0 0.0 0.0 0.0]) + r1 (d/nilv (get shape :r1) 0.0) + r2 (d/nilv (get shape :r2) 0.0) + r3 (d/nilv (get shape :r3) 0.0) + r4 (d/nilv (get shape :r4) 0.0)] + + (write-uuid! dview offset id) + (write-uuid! dview (+ offset 16) (d/nilv parent-id uuid/zero)) + (buf/write-u8 dview (+ offset 32) (sr/translate-shape-type shape-type)) + (buf/write-u8 dview (+ offset 33) flags) + (buf/write-u8 dview (+ offset 34) blend-mode) + (buf/write-u8 dview (+ offset 35) constraint-h) + (buf/write-u8 dview (+ offset 36) constraint-v) + (buf/write-f32 dview (+ offset 40) opacity) + (buf/write-f32 dview (+ offset 44) rotation) + (buf/write-f32 dview (+ offset 48) ta) + (buf/write-f32 dview (+ offset 52) tb) + (buf/write-f32 dview (+ offset 56) tc) + (buf/write-f32 dview (+ offset 60) td) + (buf/write-f32 dview (+ offset 64) te) + (buf/write-f32 dview (+ offset 68) tf) + (buf/write-f32 dview (+ offset 72) sx1) + (buf/write-f32 dview (+ offset 76) sy1) + (buf/write-f32 dview (+ offset 80) sx2) + (buf/write-f32 dview (+ offset 84) sy2) + (buf/write-f32 dview (+ offset 88) r1) + (buf/write-f32 dview (+ offset 92) r2) + (buf/write-f32 dview (+ offset 96) r3) + (buf/write-f32 dview (+ offset 100) r4) + (+ offset BASE-PROPS-SIZE))) + +(defn- write-blur! + [dview offset blur] + (buf/write-u8 dview offset (if (get blur :hidden) 1 0)) + (buf/write-f32 dview (+ offset 4) (get blur :value 0)) + (+ offset 8)) + +(defn- write-shadow! + [dview offset shadow] + (let [color (get shadow :color) + rgba (sr-clr/hex->u32argb (get color :color) + (get color :opacity))] + (buf/write-u32 dview offset rgba) + (buf/write-f32 dview (+ offset 4) (get shadow :blur 0)) + (buf/write-f32 dview (+ offset 8) (get shadow :spread 0)) + (buf/write-f32 dview (+ offset 12) (get shadow :offset-x 0)) + (buf/write-f32 dview (+ offset 16) (get shadow :offset-y 0)) + (buf/write-u8 dview (+ offset 20) (sr/translate-shadow-style (get shadow :style))) + (buf/write-u8 dview (+ offset 21) (if (get shadow :hidden) 1 0)) + (+ offset 24))) + +(defn- write-flex! + [dview offset shape] + (let [dir (-> (get shape :layout-flex-dir :row) + (sr/translate-layout-flex-dir)) + gap (get shape :layout-gap) + row-gap (get gap :row-gap 0) + column-gap (get gap :column-gap 0) + align-items (-> (get shape :layout-align-items) sr/translate-layout-align-items) + align-content (-> (get shape :layout-align-content) sr/translate-layout-align-content) + justify-items (-> (get shape :layout-justify-items) sr/translate-layout-justify-items) + justify-content (-> (get shape :layout-justify-content) sr/translate-layout-justify-content) + wrap-type (-> (get shape :layout-wrap-type) sr/translate-layout-wrap-type) + padding (get shape :layout-padding) + padding-top (get padding :p1 0) + padding-right (get padding :p2 0) + padding-bottom (get padding :p3 0) + padding-left (get padding :p4 0)] + (buf/write-u8 dview offset dir) + (buf/write-u8 dview (+ offset 1) align-items) + (buf/write-u8 dview (+ offset 2) align-content) + (buf/write-u8 dview (+ offset 3) justify-items) + (buf/write-u8 dview (+ offset 4) justify-content) + (buf/write-u8 dview (+ offset 5) wrap-type) + (buf/write-f32 dview (+ offset 8) row-gap) + (buf/write-f32 dview (+ offset 12) column-gap) + (buf/write-f32 dview (+ offset 16) padding-top) + (buf/write-f32 dview (+ offset 20) padding-right) + (buf/write-f32 dview (+ offset 24) padding-bottom) + (buf/write-f32 dview (+ offset 28) padding-left) + (+ offset 32))) + +(defn- write-layout-item! + [dview offset shape] + (let [margins (get shape :layout-item-margin) + margin-top (get margins :m1 0) + margin-right (get margins :m2 0) + margin-bottom (get margins :m3 0) + margin-left (get margins :m4 0) + h-sizing (-> (get shape :layout-item-h-sizing) sr/translate-layout-sizing) + v-sizing (-> (get shape :layout-item-v-sizing) sr/translate-layout-sizing) + align-self (-> (get shape :layout-item-align-self) sr/translate-align-self) + max-h (get shape :layout-item-max-h) + min-h (get shape :layout-item-min-h) + max-w (get shape :layout-item-max-w) + min-w (get shape :layout-item-min-w) + is-absolute (boolean (get shape :layout-item-absolute)) + z-index (get shape :layout-item-z-index) + flags (cond-> 0 + (some? max-h) (bit-or 0x01) + (some? min-h) (bit-or 0x02) + (some? max-w) (bit-or 0x04) + (some? min-w) (bit-or 0x08) + is-absolute (bit-or 0x10))] + (buf/write-f32 dview offset margin-top) + (buf/write-f32 dview (+ offset 4) margin-right) + (buf/write-f32 dview (+ offset 8) margin-bottom) + (buf/write-f32 dview (+ offset 12) margin-left) + (buf/write-u8 dview (+ offset 16) (d/nilv h-sizing 0)) + (buf/write-u8 dview (+ offset 17) (d/nilv v-sizing 0)) + (buf/write-u8 dview (+ offset 18) flags) + (buf/write-u8 dview (+ offset 19) (d/nilv align-self 0)) + (buf/write-f32 dview (+ offset 20) (d/nilv max-h 0)) + (buf/write-f32 dview (+ offset 24) (d/nilv min-h 0)) + (buf/write-f32 dview (+ offset 28) (d/nilv max-w 0)) + (buf/write-f32 dview (+ offset 32) (d/nilv min-w 0)) + (buf/write-i32 dview (+ offset 36) (d/nilv z-index 0)) + (+ offset 40))) + +(defn- write-fills-section! + "Write fills in the same layout as `_set_shape_fills`: + [u8 n][u8;3 pad][n × FILL-U8-SIZE]. Returns next offset." + [dview offset fills] + (let [fills (types.fills/coerce (or fills [])) + byte-size (types.fills/get-byte-size fills) + ;; write-to expects a Uint32Array heap + u32 element offset + heap-u32 (js/Uint32Array. (.-buffer dview)) + u32-off (quot offset 4)] + (types.fills/write-to fills heap-u32 u32-off) + (+ offset byte-size))) + +(defn- write-stroke-fill! + [dview offset stroke] + (let [opacity (or (:stroke-opacity stroke) 1.0) + color (:stroke-color stroke) + gradient (:stroke-color-gradient stroke) + image (:stroke-image stroke)] + (cond + (some? gradient) + (types.fills.impl/write-gradient-fill offset dview opacity gradient) + + (some? image) + (types.fills.impl/write-image-fill offset dview opacity image) + + (some? color) + (types.fills.impl/write-solid-fill offset dview opacity color) + + :else + (types.fills.impl/write-solid-fill offset dview 0.0 "#000000")))) + +(defn- write-stroke! + [dview offset stroke] + (let [width (or (:stroke-width stroke) 1.0) + style (-> stroke :stroke-style sr/translate-stroke-style) + align (case (:stroke-alignment stroke) + :inner STROKE-ALIGN-INNER + :outer STROKE-ALIGN-OUTER + STROKE-ALIGN-CENTER) + cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap) + cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap) + dash (or (:stroke-dash stroke) -1) + gap (or (:stroke-gap stroke) -1) + per-side? (boolean (:stroke-per-side stroke)) + top (or (:stroke-width-top stroke) width) + right (or (:stroke-width-right stroke) width) + bottom (or (:stroke-width-bottom stroke) width) + left (or (:stroke-width-left stroke) width) + has-sides? (and per-side? (not= top right bottom left))] + (buf/write-f32 dview offset width) + (buf/write-u8 dview (+ offset 4) style) + (buf/write-u8 dview (+ offset 5) align) + (buf/write-u8 dview (+ offset 6) (d/nilv cap-start 0)) + (buf/write-u8 dview (+ offset 7) (d/nilv cap-end 0)) + (buf/write-f32 dview (+ offset 8) dash) + (buf/write-f32 dview (+ offset 12) gap) + (buf/write-u8 dview (+ offset 16) (if has-sides? 1 0)) + (buf/write-f32 dview (+ offset 20) top) + (buf/write-f32 dview (+ offset 24) right) + (buf/write-f32 dview (+ offset 28) bottom) + (buf/write-f32 dview (+ offset 32) left) + (write-stroke-fill! dview (+ offset STROKE-HEADER-U8-SIZE) stroke) + (+ offset STROKE-HEADER-U8-SIZE types.fills.impl/FILL-U8-SIZE))) + +(defn- visible-strokes + [shape] + (let [type (dm/get-prop shape :type)] + (if (= type :group) + [] + (into [] (remove :hidden) (or (get shape :strokes) []))))) + +(defn- write-strokes-section! + [dview offset strokes] + (buf/write-u32 dview offset (count strokes)) + (reduce (fn [o s] (write-stroke! dview o s)) + (+ offset 4) + strokes)) + +(defn write-shape-payload! + "Serialize one shape's structural payload into `dview` starting at `offset` + (payload only — no length prefix). Returns the offset after the payload. + + Options: + - `:include-layout?` — when true, emit FLEX + LAYOUT-ITEM (workspace cold load). + - `:include-fills-strokes?` — when true, emit FILLS + STROKES sections." + [dview offset shape {:keys [include-layout? include-fills-strokes?] + :or {include-layout? false + include-fills-strokes? false}}] + (let [shape-type (dm/get-prop shape :type) + children (into [] (filter uuid?) (get shape :shapes)) + blur (get shape :blur) + bg-blur (get shape :background-blur) + shadows (or (get shape :shadow) []) + masked? (and (= shape-type :group) (boolean (get shape :masked-group))) + bool-type (when (= shape-type :bool) (get shape :bool-type)) + grow-type (when (= shape-type :text) (get shape :grow-type)) + flex? (and include-layout? (ctl/flex-layout? shape)) + layout-item? include-layout? + strokes (when include-fills-strokes? (visible-strokes shape)) + + mask (cond-> 0 + true (bit-or SECTION-CHILDREN) + (some? blur) (bit-or SECTION-BLUR-LAYER) + (some? bg-blur) (bit-or SECTION-BLUR-BG) + (seq shadows) (bit-or SECTION-SHADOWS) + (= shape-type :group) (bit-or SECTION-MASKED) + (some? bool-type) (bit-or SECTION-BOOL-TYPE) + (some? grow-type) (bit-or SECTION-GROW-TYPE) + flex? (bit-or SECTION-FLEX) + layout-item? (bit-or SECTION-LAYOUT-ITEM) + include-fills-strokes? (bit-or SECTION-FILLS) + include-fills-strokes? (bit-or SECTION-STROKES)) + + offset (write-base-props! dview offset shape) + _ (buf/write-u32 dview offset mask) + offset (+ offset 4) + + offset (let [o offset] + (buf/write-u32 dview o (count children)) + (reduce (fn [o id] (write-uuid! dview o id)) + (+ o 4) + children)) + + offset (cond-> offset + (some? blur) + (as-> o (write-blur! dview o blur))) + + offset (cond-> offset + (some? bg-blur) + (as-> o (write-blur! dview o bg-blur))) + + offset (cond-> offset + (seq shadows) + (as-> o + (do + (buf/write-u32 dview o (count shadows)) + (reduce (fn [o s] (write-shadow! dview o s)) + (+ o 4) + shadows)))) + + offset (cond-> offset + (= shape-type :group) + (as-> o + (do (buf/write-u8 dview o (if masked? 1 0)) + (+ o 4)))) + + offset (cond-> offset + (some? bool-type) + (as-> o + (do (buf/write-u8 dview o (sr/translate-bool-type bool-type)) + (+ o 4)))) + + offset (cond-> offset + (some? grow-type) + (as-> o + (do (buf/write-u8 dview o (sr/translate-grow-type grow-type)) + (+ o 4)))) + + ;; FLEX before LAYOUT-ITEM (Rust clears layout on flex) + offset (cond-> offset + flex? + (as-> o (write-flex! dview o shape))) + + offset (cond-> offset + layout-item? + (as-> o (write-layout-item! dview o shape))) + + offset (cond-> offset + include-fills-strokes? + (as-> o (write-fills-section! dview o (get shape :fills)))) + + offset (cond-> offset + include-fills-strokes? + (as-> o (write-strokes-section! dview o strokes)))] + offset)) + +(defn- payload-byte-size + [shape {:keys [include-layout? include-fills-strokes?] + :or {include-layout? false include-fills-strokes? false}}] + (let [children (into [] (filter uuid?) (get shape :shapes)) + shadows (or (get shape :shadow) []) + shape-type (dm/get-prop shape :type) + blur (get shape :blur) + bg-blur (get shape :background-blur) + flex? (and include-layout? (ctl/flex-layout? shape)) + fills-size (if include-fills-strokes? + (types.fills/get-byte-size (types.fills/coerce (or (get shape :fills) []))) + 0) + strokes (when include-fills-strokes? (visible-strokes shape)) + strokes-size (if include-fills-strokes? + (+ 4 (* (count strokes) + (+ STROKE-HEADER-U8-SIZE types.fills.impl/FILL-U8-SIZE))) + 0)] + (+ BASE-PROPS-SIZE + 4 ;; mask + (+ 4 (* 16 (count children))) + (if (some? blur) 8 0) + (if (some? bg-blur) 8 0) + (if (seq shadows) (+ 4 (* 24 (count shadows))) 0) + (if (= shape-type :group) 4 0) + (if (and (= shape-type :bool) (some? (get shape :bool-type))) 4 0) + (if (and (= shape-type :text) (some? (get shape :grow-type))) 4 0) + (if flex? 32 0) + (if include-layout? 40 0) + fills-size + strokes-size))) + +(defn- encode-shape-record + "Returns a Uint8Array: [u32 payload_len][payload]." + [shape opts] + (let [capacity (+ 4 (payload-byte-size shape opts)) + buffer (js/ArrayBuffer. capacity) + dview (js/DataView. buffer) + end (write-shape-payload! dview 4 shape opts) + payload-len (- end 4)] + (assert (= end capacity) + (str "upload record size mismatch: wrote " end " expected " capacity)) + (buf/write-u32 dview 0 payload-len) + (js/Uint8Array. buffer 0 end))) + +(defn flush-shapes-batch! + "Upload `shapes` as one `_set_shapes_batch` call. + `opts` passed to each record writer (`:include-layout?`, + `:include-fills-strokes?`)." + [shapes opts] + (when (and (wasm/live?) (seq shapes)) + (let [records (mapv #(encode-shape-record % opts) shapes) + total (reduce (fn [acc ^js u8] (+ acc (.-byteLength u8))) 4 records) + offset (mem/alloc total) + heap (mem/get-heap-u8) + dview (js/DataView. (.-buffer heap))] + (buf/write-u32 dview offset (count records)) + (reduce (fn [o ^js u8] + (.set heap u8 o) + (+ o (.-byteLength u8))) + (+ offset 4) + records) + (h/call wasm/internal-module "_set_shapes_batch") + nil))) + +(defn set-shape-upload! + "Single-shape structural upload (enlarged blob, one FFI)." + ([shape] + (set-shape-upload! shape {:include-layout? false})) + ([shape opts] + (flush-shapes-batch! [shape] opts))) diff --git a/common/src/app/common/render_wasm/enums.clj b/common/src/app/common/render_wasm/enums.clj new file mode 100644 index 0000000000..0be29aacf9 --- /dev/null +++ b/common/src/app/common/render_wasm/enums.clj @@ -0,0 +1,54 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.common.render-wasm.enums + "Serializer enum table from `shared.js`") + +(def ^:private serializer-exports + [["raster-format" "RasterFormat"] + ["blur-type" "RawBlurType"] + ["blend-mode" "RawBlendMode"] + ["bool-type" "RawBoolType"] + ["font-style" "RawFontStyle"] + ["flex-direction" "RawFlexDirection"] + ["grid-direction" "RawGridDirection"] + ["grow-type" "RawGrowType"] + ["align-items" "RawAlignItems"] + ["align-self" "RawAlignSelf"] + ["align-content" "RawAlignContent"] + ["justify-items" "RawJustifyItems"] + ["justify-content" "RawJustifyContent"] + ["justify-self" "RawJustifySelf"] + ["wrap-type" "RawWrapType"] + ["grid-track-type" "RawGridTrackType"] + ["shadow-style" "RawShadowStyle"] + ["guide-kind" "RawGuideKind"] + ["stroke-style" "RawStrokeStyle"] + ["stroke-cap" "RawStrokeCap"] + ["shape-type" "RawShapeType"] + ["constraint-h" "RawConstraintH"] + ["constraint-v" "RawConstraintV"] + ["sizing" "RawSizing"] + ["vertical-align" "RawVerticalAlign"] + ["fill-data" "RawFillData"] + ["text-align" "RawTextAlign"] + ["text-direction" "RawTextDirection"] + ["text-decoration" "RawTextDecoration"] + ["text-transform" "RawTextTransform"] + ["multiple-state" "MultipleState"] + ["transform-entry-kind" "RawTransformEntryKind"] + ["segment-data" "RawSegmentData"] + ["stroke-linecap" "RawStrokeLineCap"] + ["stroke-linejoin" "RawStrokeLineJoin"] + ["fill-rule" "RawFillRule"]]) + +(defmacro serializers + [alias] + (let [alias (name alias)] + `(cljs.core/js-obj + ~@(mapcat (fn [[key export]] + [key (symbol alias export)]) + serializer-exports)))) diff --git a/frontend/src/app/render_wasm/helpers.cljc b/common/src/app/common/render_wasm/helpers.cljc similarity index 92% rename from frontend/src/app/render_wasm/helpers.cljc rename to common/src/app/common/render_wasm/helpers.cljc index 452e3f1eb4..d96cc9f990 100644 --- a/frontend/src/app/render_wasm/helpers.cljc +++ b/common/src/app/common/render_wasm/helpers.cljc @@ -2,10 +2,10 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL -(ns app.render-wasm.helpers - #?(:cljs (:require-macros [app.render-wasm.helpers])) +(ns app.common.render-wasm.helpers + #?(:cljs (:require-macros [app.common.render-wasm.helpers])) (:require [app.common.data :as d])) (def error-code diff --git a/frontend/src/app/render_wasm/mem.cljs b/common/src/app/common/render_wasm/mem.cljs similarity index 96% rename from frontend/src/app/render_wasm/mem.cljs rename to common/src/app/common/render_wasm/mem.cljs index d90d2f7fa7..43a4435f68 100644 --- a/frontend/src/app/render_wasm/mem.cljs +++ b/common/src/app/common/render_wasm/mem.cljs @@ -2,13 +2,13 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL -(ns app.render-wasm.mem +(ns app.common.render-wasm.mem (:require [app.common.buffer :as buf] - [app.render-wasm.helpers :as h] - [app.render-wasm.wasm :as wasm])) + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.wasm :as wasm])) (defn ->offset-32 "Convert a 8-bit (1 byte) offset to a 32-bit (4 bytes) offset" diff --git a/frontend/src/app/render_wasm/mem/heap32.cljs b/common/src/app/common/render_wasm/mem/heap32.cljs similarity index 95% rename from frontend/src/app/render_wasm/mem/heap32.cljs rename to common/src/app/common/render_wasm/mem/heap32.cljs index 07ac6b749f..a07986eb20 100644 --- a/frontend/src/app/render_wasm/mem/heap32.cljs +++ b/common/src/app/common/render_wasm/mem/heap32.cljs @@ -2,9 +2,9 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL -(ns app.render-wasm.mem.heap32 +(ns app.common.render-wasm.mem.heap32 "A memory write helpers that uses 32 bits addressed offsets." (:require [app.common.data.macros :as dm] diff --git a/common/src/app/common/render_wasm/serialize_shape.cljs b/common/src/app/common/render_wasm/serialize_shape.cljs new file mode 100644 index 0000000000..868c0321f5 --- /dev/null +++ b/common/src/app/common/render_wasm/serialize_shape.cljs @@ -0,0 +1,40 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.common.render-wasm.serialize-shape + "Single source of truth for the host-independent part of serializing a whole + shape into the WASM design state. + + Both batch serializers call this so they can't drift: + - the workspace `app.render-wasm.api/set-object` (browser), and + - the headless exporter `app.wasm.serialize/set-shape!` (Node). + + Structural attrs (base, children, blur, shadows, masked, bool, grow) go + through the enlarged `_set_shapes_batch` upload. Path geometry stays on the + chunked path FFI. Host-specific parts remain in each caller AFTER this runs: + - fills / strokes image bytes (records may already be in cold-load batch), + - text content (fonts), + - svg-raw markup (browser React), + - layout (grid/flex — workspace cold-load batches flex+item via upload; + incremental edits still use `set-shape-layout` / `set-layout-data`). + + The incremental workspace edit path (`set-wasm-attr!`) is unaffected; it keeps + dispatching per changed key through the same underlying `props` setters." + (:require + [app.common.render-wasm.api.props :as props] + [app.common.render-wasm.api.upload :as upload])) + +(defn serialize-shape! + "Applies every host-independent WASM property of `shape`." + [shape] + (let [type (get shape :type)] + (upload/set-shape-upload! shape {:include-layout? false}) + + (when (some? (get shape :svg-attrs)) + (props/set-shape-svg-attrs (get shape :svg-attrs))) + + (when (and (contains? #{:path :bool} type) (some? (get shape :content))) + (props/set-shape-path-content (get shape :content))))) diff --git a/frontend/src/app/render_wasm/serializers.cljs b/common/src/app/common/render_wasm/serializers.cljs similarity index 97% rename from frontend/src/app/render_wasm/serializers.cljs rename to common/src/app/common/render_wasm/serializers.cljs index 83ecf4f4d2..02fe6632d2 100644 --- a/frontend/src/app/render_wasm/serializers.cljs +++ b/common/src/app/common/render_wasm/serializers.cljs @@ -2,18 +2,18 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL - (ns app.render-wasm.serializers + (ns app.common.render-wasm.serializers (:require [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] + [app.common.render-wasm.serializers.color :as sr-clr] + [app.common.render-wasm.wasm :as wasm] [app.common.types.color :as clr] [app.common.types.shape-tree :as ctst] [app.common.uuid :as uuid] - [app.render-wasm.serializers.color :as sr-clr] - [app.render-wasm.wasm :as wasm] [cuerdas.core :as str])) (defn u8 @@ -116,13 +116,13 @@ (defn translate-constraint-h [type] (let [values (unchecked-get wasm/serializers "constraint-h") - default 5] ;; TODO: fix code in rust so we have a proper None variant + default (unchecked-get values "none")] (d/nilv (unchecked-get values (d/name type)) default))) (defn translate-constraint-v [type] (let [values (unchecked-get wasm/serializers "constraint-v") - default 5] ;; TODO: fix code in rust so we have a proper None variant + default (unchecked-get values "none")] (d/nilv (unchecked-get values (d/name type)) default))) (defn translate-bool-type diff --git a/frontend/src/app/render_wasm/serializers/color.cljs b/common/src/app/common/render_wasm/serializers/color.cljs similarity index 90% rename from frontend/src/app/render_wasm/serializers/color.cljs rename to common/src/app/common/render_wasm/serializers/color.cljs index 7d5d28b8c7..c3ef27fdfc 100644 --- a/frontend/src/app/render_wasm/serializers/color.cljs +++ b/common/src/app/common/render_wasm/serializers/color.cljs @@ -1,4 +1,4 @@ -(ns app.render-wasm.serializers.color +(ns app.common.render-wasm.serializers.color (:require [app.common.math :as mth])) diff --git a/frontend/src/app/render_wasm/text_content.cljs b/common/src/app/common/render_wasm/text_content.cljs similarity index 90% rename from frontend/src/app/render_wasm/text_content.cljs rename to common/src/app/common/render_wasm/text_content.cljs index 457a633066..1ffbebb054 100644 --- a/frontend/src/app/render_wasm/text_content.cljs +++ b/common/src/app/common/render_wasm/text_content.cljs @@ -2,25 +2,26 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL -(ns app.render-wasm.text-content +(ns app.common.render-wasm.text-content "Single source of truth for writing a text shape's content into the WASM design state. The binary layout ([num-spans][paragraph attrs][span attrs][text]) is - identical for the workspace and the headless exporter — only *font resolution* - differs (the workspace uses the loaded fonts DB; the exporter uses its gfonts - catalog + custom variants). So the byte-writing lives here and font resolution - is injected via the `opts` map passed to `write-shape-text!`. + identical for the workspace and the headless exporter, and so is the font-id + -> uuid mapping (`cfnt/font-id->uuid`). Only *variant* resolution differs — + the workspace has a loaded fonts DB, the exporter does not — so that part is + injected via the `opts` map passed to `write-shape-text!`. Fully portable (no store/DOM/React), so it runs under Node too." (:require [app.common.data :as d] + [app.common.fonts :as cfnt] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.wasm :as wasm] [app.common.types.fills.impl :as types.fills.impl] [app.common.uuid :as uuid] - [app.render-wasm.helpers :as h] - [app.render-wasm.mem :as mem] - [app.render-wasm.serializers :as sr] - [app.render-wasm.wasm :as wasm] [cuerdas.core :as str])) (def ^:const PARAGRAPH-ATTR-U8-SIZE 12) @@ -169,13 +170,15 @@ "Writes one paragraph's spans + text into WASM and appends it to the current shape via `_set_shape_text_content`. - `opts` injects host-specific font resolution: - - `:normalize-font-id` (string font-id -> wasm uuid) — required in practice, + `opts` injects host-specific font handling: + - `:normalize-font-id` (string font-id -> wasm uuid) defaults to the shared + `cfnt/font-id->uuid`, which is what both hosts want — a host only + overrides it if it keys its font store some other way, - `:normalize-paragraph`/`:normalize-span` — font-variant normalization from a fonts DB (workspace); default to identity (the exporter resolves variants differently / not at all)." [spans paragraph text {:keys [normalize-font-id normalize-paragraph normalize-span] - :or {normalize-font-id identity + :or {normalize-font-id cfnt/font-id->uuid normalize-paragraph identity normalize-span (fn [span _paragraph] span)}}] (let [paragraph (normalize-paragraph paragraph) diff --git a/frontend/src/app/render_wasm/wasm.cljs b/common/src/app/common/render_wasm/wasm.cljs similarity index 56% rename from frontend/src/app/render_wasm/wasm.cljs rename to common/src/app/common/render_wasm/wasm.cljs index 933b200530..81b21ca5d7 100644 --- a/frontend/src/app/render_wasm/wasm.cljs +++ b/common/src/app/common/render_wasm/wasm.cljs @@ -2,10 +2,9 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL -(ns app.render-wasm.wasm - (:require ["./api/shared.js" :as shared])) +(ns app.common.render-wasm.wasm) (defonce internal-frame-id nil) (defonce internal-frame-type 0) @@ -59,47 +58,26 @@ WebGL is ready before re-init finishes." [] (set! internal-frame-id nil) + (set! internal-frame-type 0) (set! canvas nil) (set! canvas-snapshot nil) (set! gl-context-handle nil) (set! gl-context nil) (set! context-initialized? false)) -(defonce serializers - #js {:raster-format shared/RasterFormat - :blur-type shared/RawBlurType - :blend-mode shared/RawBlendMode - :bool-type shared/RawBoolType - :font-style shared/RawFontStyle - :flex-direction shared/RawFlexDirection - :grid-direction shared/RawGridDirection - :grow-type shared/RawGrowType - :align-items shared/RawAlignItems - :align-self shared/RawAlignSelf - :align-content shared/RawAlignContent - :justify-items shared/RawJustifyItems - :justify-content shared/RawJustifyContent - :justify-self shared/RawJustifySelf - :wrap-type shared/RawWrapType - :grid-track-type shared/RawGridTrackType - :shadow-style shared/RawShadowStyle - :guide-kind shared/RawGuideKind - :stroke-style shared/RawStrokeStyle - :stroke-cap shared/RawStrokeCap - :shape-type shared/RawShapeType - :constraint-h shared/RawConstraintH - :constraint-v shared/RawConstraintV - :sizing shared/RawSizing - :vertical-align shared/RawVerticalAlign - :fill-data shared/RawFillData - :text-align shared/RawTextAlign - :text-direction shared/RawTextDirection - :text-decoration shared/RawTextDecoration - :text-transform shared/RawTextTransform - :multiple-state shared/MultipleState - :transform-entry-kind shared/RawTransformEntryKind - :segment-data shared/RawSegmentData - :stroke-linecap shared/RawStrokeLineCap - :stroke-linejoin shared/RawStrokeLineJoin - :fill-rule shared/RawFillRule}) +(defonce serializers nil) + +(defn init-serializers! + "Binds the enum table produced by the `enums/serializers` macro." + [table] + (let [missing (array)] + (doseq [key (js/Object.keys table)] + (when (undefined? (unchecked-get table key)) + (.push missing key))) + + (when (pos? (alength missing)) + (throw (ex-info "stale or incomplete render-wasm shared.js" + {:missing (vec missing)}))) + + (set! serializers table))) diff --git a/common/src/app/common/schema.cljc b/common/src/app/common/schema.cljc index fba8169bcd..3ecfd15c37 100644 --- a/common/src/app/common/schema.cljc +++ b/common/src/app/common/schema.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.schema (:refer-clojure :exclude [deref merge parse-uuid parse-long parse-double parse-boolean type keys select-keys]) diff --git a/common/src/app/common/schema/desc_js_like.cljc b/common/src/app/common/schema/desc_js_like.cljc index f3d772541a..c837789224 100644 --- a/common/src/app/common/schema/desc_js_like.cljc +++ b/common/src/app/common/schema/desc_js_like.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.schema.desc-js-like (:require diff --git a/common/src/app/common/schema/desc_native.cljc b/common/src/app/common/schema/desc_native.cljc index 003400e101..b226b5bdc1 100644 --- a/common/src/app/common/schema/desc_native.cljc +++ b/common/src/app/common/schema/desc_native.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.schema.desc-native (:require diff --git a/common/src/app/common/schema/generators.cljc b/common/src/app/common/schema/generators.cljc index 1268d08ae4..c5069f400b 100644 --- a/common/src/app/common/schema/generators.cljc +++ b/common/src/app/common/schema/generators.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.schema.generators (:refer-clojure :exclude [set subseq uuid filter map let boolean vector keyword int double not-empty]) diff --git a/common/src/app/common/schema/messages.cljc b/common/src/app/common/schema/messages.cljc index 912acc7686..90ee3a21ce 100644 --- a/common/src/app/common/schema/messages.cljc +++ b/common/src/app/common/schema/messages.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.schema.messages (:require diff --git a/common/src/app/common/schema/openapi.cljc b/common/src/app/common/schema/openapi.cljc index 03291a24da..d9ccd2be36 100644 --- a/common/src/app/common/schema/openapi.cljc +++ b/common/src/app/common/schema/openapi.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.schema.openapi (:require diff --git a/common/src/app/common/schema/registry.cljc b/common/src/app/common/schema/registry.cljc index ad1fcffdbc..edb262f55c 100644 --- a/common/src/app/common/schema/registry.cljc +++ b/common/src/app/common/schema/registry.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.schema.registry (:require diff --git a/common/src/app/common/schema/test.cljc b/common/src/app/common/schema/test.cljc index b800651cb3..1ad26ddbc6 100644 --- a/common/src/app/common/schema/test.cljc +++ b/common/src/app/common/schema/test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.schema.test (:refer-clojure :exclude [for]) diff --git a/common/src/app/common/spec.cljc b/common/src/app/common/spec.cljc index 26f84a9a07..30d97be4c9 100644 --- a/common/src/app/common/spec.cljc +++ b/common/src/app/common/spec.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.spec "Data validation & assertion helpers." diff --git a/common/src/app/common/svg.cljc b/common/src/app/common/svg.cljc index 69d35c3ad1..313c2e674f 100644 --- a/common/src/app/common/svg.cljc +++ b/common/src/app/common/svg.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.svg (:require diff --git a/common/src/app/common/svg/path.cljc b/common/src/app/common/svg/path.cljc index 98dcfc6e04..628c024e6e 100644 --- a/common/src/app/common/svg/path.cljc +++ b/common/src/app/common/svg/path.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.svg.path #?(:clj diff --git a/common/src/app/common/svg/path/Parser.java b/common/src/app/common/svg/path/Parser.java index 482cf1676d..4c653c6349 100644 --- a/common/src/app/common/svg/path/Parser.java +++ b/common/src/app/common/svg/path/Parser.java @@ -2,7 +2,7 @@ * Performance focused pure java implementation of the * SVG path parser. * - * @author KALEIDOS INC Sucursal en España SL + * @author KALEIDOS SUBSIDIARY SL * @license MPL-2.0 <https://www.mozilla.org/en-US/MPL/2.0/> */ diff --git a/common/src/app/common/svg/path/arc_to_bezier.js b/common/src/app/common/svg/path/arc_to_bezier.js index f7911a8b24..25a102e638 100644 --- a/common/src/app/common/svg/path/arc_to_bezier.js +++ b/common/src/app/common/svg/path/arc_to_bezier.js @@ -5,7 +5,7 @@ * functions by https://github.com/fontello/svgpath used as reference * implementation for tests * - * @author KALEIDOS INC Sucursal en España SL + * @author KALEIDOS SUBSIDIARY SL * @license MIT License <https://opensource.org/licenses/MIT> */ diff --git a/common/src/app/common/svg/path/legacy_parser2.cljc b/common/src/app/common/svg/path/legacy_parser2.cljc index 8af87feb90..3b28178902 100644 --- a/common/src/app/common/svg/path/legacy_parser2.cljc +++ b/common/src/app/common/svg/path/legacy_parser2.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.svg.path.legacy-parser2 "The second SVG Path parser implementation. diff --git a/common/src/app/common/svg/path/parser.js b/common/src/app/common/svg/path/parser.js index dd102f5d55..1007180f6b 100644 --- a/common/src/app/common/svg/path/parser.js +++ b/common/src/app/common/svg/path/parser.js @@ -2,7 +2,7 @@ * Performance focused pure javascript implementation of the * SVG path parser. * - * @author KALEIDOS INC Sucursal en España SL + * @author KALEIDOS SUBSIDIARY SL * @license MPL-2.0 <https://www.mozilla.org/en-US/MPL/2.0/> */ diff --git a/common/src/app/common/test_helpers/components.cljc b/common/src/app/common/test_helpers/components.cljc index 2d214e8c78..248b08619a 100644 --- a/common/src/app/common/test_helpers/components.cljc +++ b/common/src/app/common/test_helpers/components.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.test-helpers.components (:require diff --git a/common/src/app/common/test_helpers/compositions.cljc b/common/src/app/common/test_helpers/compositions.cljc index 5089e8b3e6..a4fa6b748b 100644 --- a/common/src/app/common/test_helpers/compositions.cljc +++ b/common/src/app/common/test_helpers/compositions.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.test-helpers.compositions (:require diff --git a/common/src/app/common/test_helpers/files.cljc b/common/src/app/common/test_helpers/files.cljc index 027b095a5f..448e0d2562 100644 --- a/common/src/app/common/test_helpers/files.cljc +++ b/common/src/app/common/test_helpers/files.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.test-helpers.files (:require diff --git a/common/src/app/common/test_helpers/ids_map.cljc b/common/src/app/common/test_helpers/ids_map.cljc index 60649cf31d..9648b42c37 100644 --- a/common/src/app/common/test_helpers/ids_map.cljc +++ b/common/src/app/common/test_helpers/ids_map.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.test-helpers.ids-map (:require diff --git a/common/src/app/common/test_helpers/shapes.cljc b/common/src/app/common/test_helpers/shapes.cljc index 11f1a79acf..21e51d377d 100644 --- a/common/src/app/common/test_helpers/shapes.cljc +++ b/common/src/app/common/test_helpers/shapes.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.test-helpers.shapes (:require diff --git a/common/src/app/common/test_helpers/tokens.cljc b/common/src/app/common/test_helpers/tokens.cljc index 02becdc27a..81697ac4b8 100644 --- a/common/src/app/common/test_helpers/tokens.cljc +++ b/common/src/app/common/test_helpers/tokens.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.test-helpers.tokens (:require diff --git a/common/src/app/common/test_helpers/variants.cljc b/common/src/app/common/test_helpers/variants.cljc index bf2e6fc973..8bbadf4c40 100644 --- a/common/src/app/common/test_helpers/variants.cljc +++ b/common/src/app/common/test_helpers/variants.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.test-helpers.variants (:require @@ -13,6 +13,11 @@ [app.common.types.text :as txt])) (defn add-variant + "Add a variant component to a file with two variants, each with a root shape. + :variant-label [:name Board] + {:root2-label} [:name Board] # [Component :component2-label] + {:root1-label} [:name Board] # [Component :component1-label] + " [file variant-label component1-label root1-label component2-label root2-label & {:keys [variant1-params variant2-params] :or {variant1-params {} variant2-params {}}}] diff --git a/common/src/app/common/text.cljc b/common/src/app/common/text.cljc index 6cd32e61b6..45f7b6f534 100644 --- a/common/src/app/common/text.cljc +++ b/common/src/app/common/text.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.text "Legacy editor helpers (draftjs). diff --git a/common/src/app/common/time.cljc b/common/src/app/common/time.cljc index 5410ee6a49..080d609761 100644 --- a/common/src/app/common/time.cljc +++ b/common/src/app/common/time.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL #_{:clj-kondo/ignore [:unused-namespace]} (ns app.common.time diff --git a/common/src/app/common/transit.cljc b/common/src/app/common/transit.cljc index 21b73cf942..dc3e37b380 100644 --- a/common/src/app/common/transit.cljc +++ b/common/src/app/common/transit.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.transit (:require diff --git a/common/src/app/common/types/color.cljc b/common/src/app/common/types/color.cljc index a6938c4a6f..58cd5d970f 100644 --- a/common/src/app/common/types/color.cljc +++ b/common/src/app/common/types/color.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.color (:refer-clojure :exclude [test]) diff --git a/common/src/app/common/types/component.cljc b/common/src/app/common/types/component.cljc index 37a090217b..deb5379e72 100644 --- a/common/src/app/common/types/component.cljc +++ b/common/src/app/common/types/component.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.component (:require diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index 0c19718641..cc7ea9bd07 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.container (:require diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index 8c97f4cce0..bcc4ddc44d 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.file (:require @@ -28,7 +28,8 @@ [app.common.types.shape :as cts] [app.common.types.shape-tree :as ctst] [app.common.types.text :as txt] - [app.common.types.tokens-lib :refer [schema:tokens-lib]] + [app.common.types.tokens-lib :as ctob] + [app.common.types.tokens-status :as ctos] [app.common.types.typographies-list :as ctyl] [app.common.types.typography :as cty] [app.common.uuid :as uuid] @@ -86,7 +87,15 @@ [:components {:optional true} schema:components] [:typographies {:optional true} schema:typographies] [:plugin-data {:optional true} schema:plugin-data] - [:tokens-lib {:optional true} schema:tokens-lib]]) + [:tokens-source {:optional true} ::sm/uuid] ;; Forward-compat: UUID of external library containing tokens-lib (full support in follow-up PR) + [:tokens-lib {:optional true} ctob/schema:tokens-lib] + [:tokens-status {:optional true} ctos/schema:tokens-status]]) + +(def schema:file-metadata + [:map {:title "Metadata"} + [:storage-ref-id {:optional true} ::sm/uuid] + [:generated-by {:optional true} :string] + [:referer {:optional true} :string]]) (def schema:file "A schema for validate a file data structure; data is optional @@ -106,6 +115,7 @@ [:data {:optional true} schema:data] [:version :int] [:features ::cfeat/features] + [:metadata {:optional true} schema:file-metadata] [:migrations {:optional true} [::sm/set {:ordered true} :string]]]) @@ -123,6 +133,9 @@ (def check-file-media (sm/check-fn schema:media)) +(def decode-file-metadata + (sm/decoder schema:file-metadata sm/json-transformer)) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; INITIALIZATION ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -306,6 +319,7 @@ (update-objects-tree container f))))) ;; Asset helpers + (defn find-component-file [file libraries component-file] (if (and (some? file) (= component-file (:id file))) @@ -912,8 +926,10 @@ (let [shape (get objects shape-id)] (println (str/pad (str (str/repeat " " level) (when (:main-instance shape) "{") + (when (:is-variant-container shape) "{{") (:name shape) (when (:main-instance shape) "}") + (when (:is-variant-container shape) "}}") (when (seq (:touched shape)) "*") (when show-ids (str/format " %s" (:id shape)))) {:length 20 diff --git a/common/src/app/common/types/fills.cljc b/common/src/app/common/types/fills.cljc index 62bc999b55..1c9e100e4a 100644 --- a/common/src/app/common/types/fills.cljc +++ b/common/src/app/common/types/fills.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.fills (:refer-clojure :exclude [assoc update]) diff --git a/common/src/app/common/types/fills/impl.cljc b/common/src/app/common/types/fills/impl.cljc index 571b5577fd..32f806568f 100644 --- a/common/src/app/common/types/fills/impl.cljc +++ b/common/src/app/common/types/fills/impl.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.fills.impl (:require diff --git a/common/src/app/common/types/font.cljc b/common/src/app/common/types/font.cljc index 0e90a676b8..fb7c737171 100644 --- a/common/src/app/common/types/font.cljc +++ b/common/src/app/common/types/font.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.font (:require diff --git a/common/src/app/common/types/grid.cljc b/common/src/app/common/types/grid.cljc index 41220417bd..4fa1e8bf4e 100644 --- a/common/src/app/common/types/grid.cljc +++ b/common/src/app/common/types/grid.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.grid (:require diff --git a/common/src/app/common/types/library.cljc b/common/src/app/common/types/library.cljc index edc441cc91..62e703898a 100644 --- a/common/src/app/common/types/library.cljc +++ b/common/src/app/common/types/library.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.library "Exposes file library type data helpers. diff --git a/common/src/app/common/types/objects_map.cljc b/common/src/app/common/types/objects_map.cljc index ad53f4f594..77289a2b1e 100644 --- a/common/src/app/common/types/objects_map.cljc +++ b/common/src/app/common/types/objects_map.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.objects-map "Implements a specialized map-like data structure for store an UUID => diff --git a/common/src/app/common/types/organization.cljc b/common/src/app/common/types/organization.cljc index 8a79598d74..f601cf2971 100644 --- a/common/src/app/common/types/organization.cljc +++ b/common/src/app/common/types/organization.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.organization (:require diff --git a/common/src/app/common/types/page.cljc b/common/src/app/common/types/page.cljc index 5704c84e87..b4631a3698 100644 --- a/common/src/app/common/types/page.cljc +++ b/common/src/app/common/types/page.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.page (:refer-clojure :exclude [empty?]) diff --git a/common/src/app/common/types/pages_list.cljc b/common/src/app/common/types/pages_list.cljc index f55443287e..85e73c78d3 100644 --- a/common/src/app/common/types/pages_list.cljc +++ b/common/src/app/common/types/pages_list.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.pages-list (:require diff --git a/common/src/app/common/types/path.cljc b/common/src/app/common/types/path.cljc index 2b1188682f..a198e57cfa 100644 --- a/common/src/app/common/types/path.cljc +++ b/common/src/app/common/types/path.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.path (:require @@ -18,6 +18,7 @@ [app.common.types.path.helpers :as helpers] [app.common.types.path.impl :as impl] [app.common.types.path.segment :as segment] + [app.common.types.path.selection :as selection] [app.common.types.path.shape-to-path :as stp] [app.common.types.path.subpath :as subpath])) @@ -209,6 +210,120 @@ (let [content (impl/path-data content)] (segment/get-points content))) +(defn segment-entries + "Selectable path segments with their command index and endpoints." + [content] + (let [content (impl/path-data content)] + (segment/segment-entries content))) + +(defn single-line? + "True when the content is a single straight segment: a move-to + followed by exactly one line-to." + [content] + (and (some? content) + (= 2 (count content)) + (= :move-to (:command (nth content 0))) + (= :line-to (:command (nth content 1))))) + +(defn close-loops + "Closes subpaths whose endpoints meet and returns PathData." + [content] + (-> (subpath/close-loops content) + (impl/from-plain))) + +(defn extract-content + "Extracts selected segments and segments between selected nodes into new + subpaths." + [content {:keys [nodes segments]}] + (let [content (impl/path-data content) + nodes (or nodes #{}) + segments (or segments #{}) + selected? (fn [{:keys [index from-index to-index]}] + (or (contains? segments index) + (and (contains? nodes from-index) + (contains? nodes to-index)))) + entries (filterv selected? (segment/segment-entries content)) + plain (loop [entries (seq entries) + prev nil + result (transient [])] + (if-let [{:keys [from from-index to segment] :as entry} (first entries)] + (let [result (cond-> result + (not= from-index (:to-index prev)) + (conj! {:command :move-to + :params {:x (:x from) :y (:y from)}})) + result (conj! result + (if (= :close-path (:command segment)) + {:command :line-to + :params {:x (:x to) :y (:y to)}} + segment))] + (recur (next entries) entry result)) + (persistent! result)))] + (-> (close-subpaths (impl/path-data plain)) + (close-loops)))) + +(defn splice-content + "Appends sub-content as new subpaths." + [content sub-content] + (impl/path-data (into (vec content) (vec sub-content)))) + +(defn- move-segment-end + "Moves a segment endpoint and its incoming handle by `delta`." + [segment {dx :x dy :y}] + (cond-> (-> segment + (update-in [:params :x] + dx) + (update-in [:params :y] + dy)) + (= :curve-to (:command segment)) + (-> (update-in [:params :c2x] + dx) + (update-in [:params :c2y] + dy)))) + +(defn- segment->end + "Returns the command arriving at an entry's end node as a drawable segment." + [{:keys [to segment]}] + (if (= :close-path (:command segment)) + {:command :line-to :params {:x (:x to) :y (:y to)}} + segment)) + +(defn- reverse-segment + "Reverses a segment toward `from`, swapping curve handles." + [segment {fx :x fy :y}] + (if (= :curve-to (:command segment)) + (let [{:keys [c1x c1y c2x c2y]} (:params segment)] + {:command :curve-to + :params {:x fx :y fy :c1x c2x :c1y c2y :c2x c1x :c2y c1y}}) + {:command :line-to :params {:x fx :y fy}})) + +(defn duplicate-node-content + "Copies a node and its incident segments, keeping their far ends attached. + Returns copied content and the relative indices of the new node." + [content index node-offset] + (let [content (impl/path-data content) + entries (segment-entries content) + incident (filterv #(or (= index (:to-index %)) + (= index (:from-index %))) + entries)] + (if (seq incident) + (reduce (fn [{:keys [content selected]} {:keys [from to to-index segment] :as entry}] + (let [incoming? (= index to-index) + start (if incoming? from to) + end (if incoming? + (segment->end entry) + (reverse-segment segment from)) + end (cond-> end + (some? node-offset) (move-segment-end node-offset))] + {:content (conj content + {:command :move-to + :params {:x (:x start) :y (:y start)}} + end) + :selected (conj selected (inc (count content)))})) + {:content [] :selected #{}} + incident) + (when-let [{:keys [x y]} (:params (nth content index nil))] + {:content [{:command :move-to + :params (if (some? node-offset) + {:x (+ x (:x node-offset)) :y (+ y (:y node-offset))} + {:x x :y y})}] + :selected #{0}})))) + (defn calc-selrect "Calculate selrect from a content. The content can be in a PathData instance or plain vector of segments." @@ -279,6 +394,11 @@ [points & {:keys [close]}] (segment/points->content points :close close)) +(defn smooth-points->content + "Fits smooth path content through `points`." + [points tolerance] + (segment/smooth-points->content points tolerance)) + (defn closest-point "Returns the closest point in the path to position, at a given precision." [content position precision] @@ -304,6 +424,30 @@ (let [content (impl/path-data content)] (segment/split-segments content points value))) +(defn is-curve-point? + "True when a node has at least one visible handler." + [content point] + (let [content (impl/path-data content)] + (boolean (segment/is-curve? content point)))) + +(defn collapse-handler + "Collapses a handler onto its node and simplifies flat curves to lines." + [content index prefix] + (let [content (impl/path-data content)] + (segment/collapse-handler content index prefix))) + +(defn toggle-segment-curve + "Toggles a segment between a line and a curve." + [content index] + (let [content (impl/path-data content)] + (segment/toggle-segment-curve content index))) + +(defn remove-segments + "Removes segments, opening their subpaths and dropping empty ones." + [content indices] + (let [content (impl/path-data content)] + (segment/remove-segments content indices))) + (defn remove-nodes "Removes the given points from content, reconstructing paths as needed." [content points] @@ -323,10 +467,49 @@ (segment/join-nodes content points))) (defn separate-nodes - "Removes the segments between the given points." - [content points] + "Removes segments between points or splits one node into offset open ends." + ([content points] + (let [content (impl/path-data content)] + (segment/separate-nodes content points))) + ([content points offset] + (let [content (impl/path-data content)] + (segment/separate-nodes content points offset)))) + +(defn flip-content + "Flips selected nodes and handles across their bounding box." + [content indices axis] (let [content (impl/path-data content)] - (segment/separate-nodes content points))) + (selection/flip-content content indices axis))) + +(defn align-content + "Aligns selected nodes and handles within their bounding box." + [content indices axis] + (let [content (impl/path-data content)] + (selection/align-content content indices axis))) + +(defn distribute-content + "Distributes selected nodes evenly along `axis`." + [content indices axis] + (let [content (impl/path-data content)] + (selection/distribute-content content indices axis))) + +(defn set-nodes-coordinate + "Sets one coordinate of selected nodes and their handles." + [content indices axis value] + (let [content (impl/path-data content)] + (selection/set-nodes-coordinate content indices axis value))) + +(defn set-handler-points + "Moves each handler in `pts` to its target point." + [content pts] + (let [content (impl/path-data content)] + (selection/set-handler-points content pts))) + +(defn translate-selected-nodes + "Moves selected nodes and their handles by `delta`." + [content indices delta] + (let [content (impl/path-data content)] + (selection/translate-selected-nodes content indices delta))) (defn- calc-bool-content* "Calculate the boolean content from shape and objects. Returns plain diff --git a/common/src/app/common/types/path/bool.cljc b/common/src/app/common/types/path/bool.cljc index d2fdf01cb7..2193798529 100644 --- a/common/src/app/common/types/path/bool.cljc +++ b/common/src/app/common/types/path/bool.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.path.bool (:require diff --git a/common/src/app/common/types/path/fit.cljc b/common/src/app/common/types/path/fit.cljc new file mode 100644 index 0000000000..486822a79f --- /dev/null +++ b/common/src/app/common/types/path/fit.cljc @@ -0,0 +1,209 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.common.types.path.fit + "Curve fitting helpers." + (:require + [app.common.geom.point :as gpt] + [app.common.math :as mth] + [app.common.types.path.helpers :as helpers])) + +(defn- chord-length-params + "Returns normalized chord-length parameters for `points`." + [points] + (let [dists (->> (map gpt/distance points (rest points)) + (reductions + 0) + (vec)) + total (peek dists)] + (if (mth/almost-zero? total) + (let [n (max 1 (dec (count points)))] + (mapv #(/ (double %) n) (range (count points)))) + (mapv #(/ % total) dists)))) + +(defn fit-cubic + "Fits one cubic through `points` with fixed endpoints and tangents." + ([points tan1 tan2] + (let [points (vec points)] + (fit-cubic points (chord-length-params points) tan1 tan2))) + ([points params tan1 tan2] + (let [points (vec points) + p0 (first points) + p3 (peek points) + + [c00 c01 c11 x0 x1] + (reduce + (fn [[c00 c01 c11 x0 x1] [point u]] + (let [u' (- 1.0 u) + b0 (* u' u' u') + b1 (* 3.0 u u' u') + b2 (* 3.0 u u u') + b3 (* u u u) + a1 (gpt/scale tan1 b1) + a2 (gpt/scale tan2 b2) + tmp (-> point + (gpt/subtract (gpt/scale p0 (+ b0 b1))) + (gpt/subtract (gpt/scale p3 (+ b2 b3))))] + [(+ c00 (gpt/dot a1 a1)) + (+ c01 (gpt/dot a1 a2)) + (+ c11 (gpt/dot a2 a2)) + (+ x0 (gpt/dot a1 tmp)) + (+ x1 (gpt/dot a2 tmp))])) + [0.0 0.0 0.0 0.0 0.0] + (map vector points params)) + + det-c (- (* c00 c11) (* c01 c01)) + alpha1 (when-not (mth/almost-zero? det-c) + (/ (- (* x0 c11) (* x1 c01)) det-c)) + alpha2 (when-not (mth/almost-zero? det-c) + (/ (- (* c00 x1) (* c01 x0)) det-c)) + + chord (gpt/distance p0 p3) + epsilon (* 0.000001 chord) + + [alpha1 alpha2] + (if (or (nil? alpha1) (nil? alpha2) + (< alpha1 epsilon) (< alpha2 epsilon)) + [(/ chord 3.0) (/ chord 3.0)] + [alpha1 alpha2])] + + [(gpt/add p0 (gpt/scale tan1 alpha1)) + (gpt/add p3 (gpt/scale tan2 alpha2))]))) + +(defn- curve-d1 + "Returns the first derivative at `t`." + [[start end h1 h2] t] + (let [t' (- 1.0 t) + a (* 3.0 t' t') + b (* 6.0 t' t) + c (* 3.0 t t)] + (gpt/point (+ (* a (- (:x h1) (:x start))) + (* b (- (:x h2) (:x h1))) + (* c (- (:x end) (:x h2)))) + (+ (* a (- (:y h1) (:y start))) + (* b (- (:y h2) (:y h1))) + (* c (- (:y end) (:y h2))))))) + +(defn- curve-d2 + "Returns the second derivative at `t`." + [[start end h1 h2] t] + (let [t' (- 1.0 t)] + (gpt/point (+ (* 6.0 t' (+ (:x h2) (* -2.0 (:x h1)) (:x start))) + (* 6.0 t (+ (:x end) (* -2.0 (:x h2)) (:x h1)))) + (+ (* 6.0 t' (+ (:y h2) (* -2.0 (:y h1)) (:y start))) + (* 6.0 t (+ (:y end) (* -2.0 (:y h2)) (:y h1))))))) + +(defn- refine-parameter + "Moves `u` toward the closest point on `curve`." + [curve point u] + (let [d (gpt/subtract (helpers/curve-values curve u) point) + d1 (curve-d1 curve u) + d2 (curve-d2 curve u) + den (+ (gpt/dot d1 d1) (gpt/dot d d2))] + (if (mth/almost-zero? den) + u + (mth/clamp (- u (/ (gpt/dot d d1) den)) 0.0 1.0)))) + +(defn- max-fit-error + "Returns the largest interior fit error and its index." + [points params curve] + (let [n (count points)] + (loop [i 1 + max-err 0.0 + split (quot n 2)] + (if (>= i (dec n)) + [max-err split] + (let [d (gpt/subtract (helpers/curve-values curve (nth params i)) + (nth points i)) + err (gpt/dot d d)] + (if (> err max-err) + (recur (inc i) err i) + (recur (inc i) max-err split))))))) + +(def ^:private ^:const max-fit-iterations 4) + +(defn- fit-curve* + "Fits one or more curves through at least two points." + [points tan1 tan2 tol2] + (let [n (count points) + p0 (first points) + p3 (peek points)] + (if (= n 2) + (let [alpha (/ (gpt/distance p0 p3) 3.0)] + [[p0 p3 + (gpt/add p0 (gpt/scale tan1 alpha)) + (gpt/add p3 (gpt/scale tan2 alpha))]]) + + (let [params (chord-length-params points) + [h1 h2] (fit-cubic points params tan1 tan2) + curve [p0 p3 h1 h2] + [err split] (max-fit-error points params curve) + + [curve err split] + (if (and (> err tol2) (<= err (* 16.0 tol2))) + (loop [it 0 + params params + curve curve + err err + split split] + (if (or (>= it max-fit-iterations) (<= err tol2)) + [curve err split] + (let [params (mapv #(refine-parameter curve %1 %2) points params) + [h1 h2] (fit-cubic points params tan1 tan2) + curve [p0 p3 h1 h2] + [err split] (max-fit-error points params curve)] + (recur (inc it) params curve err split)))) + [curve err split])] + + (if (<= err tol2) + [curve] + (let [split (mth/clamp split 1 (- n 2)) + center (let [v (gpt/to-vec (nth points (inc split)) + (nth points (dec split)))] + (if (mth/almost-zero? (gpt/length v)) + (gpt/unit (gpt/to-vec (nth points split) + (nth points (dec split)))) + (gpt/unit v)))] + (into (fit-curve* (subvec points 0 (inc split)) tan1 center tol2) + (fit-curve* (subvec points split) (gpt/negate center) tan2 tol2)))))))) + +(def ^:private default-corner-angle 60.0) + +(defn- corner-index? + "True when point `i` turns more than `corner-angle` degrees." + [points i corner-angle] + (let [v-in (gpt/to-vec (nth points (dec i)) (nth points i)) + v-out (gpt/to-vec (nth points i) (nth points (inc i)))] + (and (not (mth/almost-zero? (gpt/length v-in))) + (not (mth/almost-zero? (gpt/length v-out))) + (> (gpt/angle-with-other v-in v-out) corner-angle)))) + +(defn fit-curve + "Fits chained cubic curves through `points` within `tolerance`." + ([points tolerance] + (fit-curve points tolerance default-corner-angle)) + ([points tolerance corner-angle] + (let [points (reduce (fn [acc point] + (if (and (seq acc) + (< (gpt/distance (peek acc) point) 0.01)) + acc + (conj acc point))) + [] + points) + n (count points)] + (when (>= n 2) + (let [tol2 (* (double tolerance) (double tolerance)) + corners (into [] (filter #(corner-index? points % corner-angle)) + (range 1 (dec n))) + bounds (concat [0] corners [(dec n)])] + (into [] + (mapcat (fn [[a b]] + (let [span (subvec points a (inc b)) + m (count span)] + (when (>= m 2) + (let [tan1 (gpt/unit (gpt/to-vec (nth span 0) (nth span 1))) + tan2 (gpt/unit (gpt/to-vec (nth span (dec m)) (nth span (- m 2))))] + (fit-curve* span tan1 tan2 tol2)))))) + (partition 2 1 bounds))))))) diff --git a/common/src/app/common/types/path/helpers.cljc b/common/src/app/common/types/path/helpers.cljc index bd0db1640b..fd9ecdf64e 100644 --- a/common/src/app/common/types/path/helpers.cljc +++ b/common/src/app/common/types/path/helpers.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.path.helpers "A collection of path internal helpers that does not depend on other @@ -72,16 +72,9 @@ nil)) (defn- closest-angle + "Snaps an angle (in degrees) to the nearest 15° increment." [angle] - (cond - (or (> angle 337.5) (<= angle 22.5)) 0 - (and (> angle 22.5) (<= angle 67.5)) 45 - (and (> angle 67.5) (<= angle 112.5)) 90 - (and (> angle 112.5) (<= angle 157.5)) 135 - (and (> angle 157.5) (<= angle 202.5)) 180 - (and (> angle 202.5) (<= angle 247.5)) 225 - (and (> angle 247.5) (<= angle 292.5)) 270 - (and (> angle 292.5) (<= angle 337.5)) 315)) + (mth/round angle 15)) (defn position-fixed-angle [point from-point] @@ -119,6 +112,13 @@ (gpt/point (-> segment :params :c1x) (-> segment :params :c1y)) (gpt/point (-> segment :params :c2x) (-> segment :params :c2y))])) +(defn entry->bezier + "Returns a segment entry as `[start end h1 h2]`." + [{:keys [from to segment]}] + (if (= :curve-to (:command segment)) + (command->bezier segment from) + [from to from to])) + (declare curve-extremities) (declare curve-values) @@ -189,6 +189,70 @@ (gpt/point (coord-v :x) (coord-v :y))))) +(defn curve-closest-t + "Finds the cubic parameter closest to `position`." + [[start end h1 h2] position precision] + (let [d (fn [t] (gpt/distance position (curve-values start end h1 h2 t)))] + (loop [t1 0.0 + t2 1.0] + (if (<= (mth/abs (- t1 t2)) precision) + t1 + (let [ht (+ t1 (/ (- t2 t1) 2)) + ht1 (+ t1 (/ (- t2 t1) 4)) + ht2 (+ t1 (/ (* 3 (- t2 t1)) 4)) + + [t1 t2] (cond + (< (d ht1) (d ht2)) [t1 ht] + (< (d ht2) (d ht1)) [ht t2] + (and (< (d ht) (d t1)) (< (d ht) (d t2))) [ht1 ht2] + (< (d t1) (d t2)) [t1 ht] + :else [ht t2])] + (recur (double t1) (double t2))))))) + +(def ^:private arc-length-samples + "Samples for approximating a cubic's length." + 100) + +(defn curve-arc-length-t + "Finds the cubic parameter at half its arc length." + [[start end h1 h2]] + (let [n arc-length-samples + pts (mapv (fn [i] (curve-values start end h1 h2 (/ (double i) n))) + (range (inc n))) + dists (->> (map gpt/distance pts (rest pts)) + (reductions + 0.0) + (vec)) + total (peek dists)] + (if (mth/almost-zero? total) + 0.5 + (let [half (/ total 2.0) + i (loop [i 0] + (if (and (< (inc i) (count dists)) + (< (nth dists (inc i)) half)) + (recur (inc i)) + i)) + d0 (nth dists i) + d1 (nth dists (inc i)) + frac (if (mth/almost-zero? (- d1 d0)) + 0.0 + (/ (- half d0) (- d1 d0)))] + (/ (+ i frac) n))))) + +(defn bend-curve-deltas + "Returns the smallest handler deltas that move the point at `t` to `target`." + [curve t target] + (let [t' (- 1.0 t) + b (* 3.0 t' t' t) + c (* 3.0 t' t t) + delta (gpt/subtract target (curve-values curve t)) + denom (+ (* b b) (* c c))] + (if (mth/almost-zero? denom) + {:c1x 0.0 :c1y 0.0 :c2x 0.0 :c2y 0.0} + (let [k1 (/ b denom) + k2 (/ c denom)] + {:c1x (* k1 (:x delta)) :c1y (* k1 (:y delta)) + :c2x (* k2 (:x delta)) :c2y (* k2 (:y delta))})))) + (defn solve-roots* "Solvers a quadratic or cubic equation given by the parameters a b c d. diff --git a/common/src/app/common/types/path/impl.cljc b/common/src/app/common/types/path/impl.cljc index 483ebdd54b..449f2cfb17 100644 --- a/common/src/app/common/types/path/impl.cljc +++ b/common/src/app/common/types/path/impl.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.path.impl "Contains schemas and data type implementation for PathData binary diff --git a/common/src/app/common/types/path/segment.cljc b/common/src/app/common/types/path/segment.cljc index d0742ced93..82c83483ca 100644 --- a/common/src/app/common/types/path/segment.cljc +++ b/common/src/app/common/types/path/segment.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.path.segment "A collection of helpers for work with plain segment type" @@ -13,8 +13,10 @@ [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] [app.common.math :as mth] + [app.common.types.path.fit :as fit] [app.common.types.path.helpers :as helpers] [app.common.types.path.impl :as impl] + [app.common.types.path.subpath :as subpath] [clojure.set :as set])) #?(:clj (set! *warn-on-reflection* true)) @@ -139,6 +141,42 @@ (gpt/point x y))) []))) +(defn segment-entries + "Returns selectable segments with their command index and endpoints." + [content] + (loop [index 0 + pending (seq content) + previous nil + previous-index nil + subpath-start nil + subpath-start-index nil + result []] + (if-let [{:keys [command] :as segment} (first pending)] + (let [close-path? (= command :close-path) + move-to? (= command :move-to) + point (if close-path? + subpath-start + (helpers/segment->point segment)) + point-index (if close-path? subpath-start-index index) + result (cond-> result + (and previous point (not move-to?)) + (conj {:index index + :from previous + :from-index previous-index + :to point + :to-index point-index + :segment segment})) + subpath-start (if move-to? point subpath-start) + subpath-start-index (if move-to? index subpath-start-index)] + (recur (inc index) + (next pending) + point + point-index + subpath-start + subpath-start-index + result)) + result))) + ;; FIXME: incorrect API, don't need full shape (defn path->lines "Given a path returns a list of lines that approximate the path" @@ -312,34 +350,6 @@ (impl/from-plain content))) -(defn- line->curve - [from-p segment] - - (let [to-p (helpers/segment->point segment) - - v (gpt/to-vec from-p to-p) - d (gpt/distance from-p to-p) - - dv1 (-> (gpt/normal-left v) - (gpt/scale (/ d 3))) - - h1 (gpt/add from-p dv1) - - dv2 (-> (gpt/to-vec to-p h1) - (gpt/unit) - (gpt/scale (/ d 3))) - - h2 (gpt/add to-p dv2)] - (-> segment - (assoc :command :curve-to) - (update :params (fn [params] - ;; ensure plain map - (-> (into {} params) - (assoc :c1x (:x h1)) - (assoc :c1y (:y h1)) - (assoc :c2x (:x h2)) - (assoc :c2y (:y h2)))))))) - ;; FIXME: optimize (defn is-curve? [content point] @@ -353,111 +363,123 @@ (mapcat #(list (:next-p %) (:prev-p %))) (remove nil?))) +(defn- curve-neighbourhood + "Returns the adjacent segments and points for one node." + [content index] + (let [segment (get content index) + prev-i (dec index) + prev (when (not= :move-to (:command segment)) + (get content prev-i)) + next-i (inc index) + next (get content next-i) + next (when (not= :move-to (:command next)) next)] + {:index index + :prev-i (when (some? prev) prev-i) + :prev-c prev + :prev-p (helpers/segment->point prev) + :next-i (when (some? next) next-i) + :next-c next + :next-p (helpers/segment->point next) + :segment segment})) + +(defn- smooth-tangent + "Returns tangent data for a smooth curve node." + [content point indices neighbourhoods neighbour-points] + (let [[first-point second-point] (vec neighbour-points) + prev-neighbour (some :prev-p neighbourhoods) + next-neighbour (some :next-p neighbourhoods) + seam? (and (= 2 (count indices)) + (= :move-to (:command (get content (first indices)))) + (let [end-index (last indices)] + (or (= end-index (dec (count content))) + (= :close-path + (:command (get content (inc end-index))))))) + first-unit (gpt/unit (gpt/to-vec point first-point)) + second-unit (gpt/unit (gpt/to-vec point second-point)) + angle-tangent (let [delta (gpt/subtract second-unit first-unit)] + (if (mth/almost-zero? (gpt/length delta)) + (gpt/perpendicular first-unit) + (gpt/unit delta))) + tangent (if seam? + (let [chord (gpt/to-vec prev-neighbour next-neighbour)] + (if (mth/almost-zero? (gpt/length chord)) + angle-tangent + (gpt/unit chord))) + angle-tangent) + length (/ (min (gpt/distance point first-point) + (gpt/distance point second-point)) + 3)] + {:tangent tangent + :length length + :seam? seam? + :prev-neighbour prev-neighbour + :next-neighbour next-neighbour})) + +(defn- smooth-handle + "Returns a smooth handle toward `neighbour`." + [point {:keys [tangent length seam? prev-neighbour next-neighbour]} neighbour] + (when (some? neighbour) + (let [direction (gpt/unit (gpt/to-vec point neighbour)) + side (cond + (and seam? (= neighbour prev-neighbour)) -1 + (and seam? (= neighbour next-neighbour)) 1 + :else (if (neg? (gpt/dot direction tangent)) -1 1))] + (gpt/add point (gpt/scale tangent (* side length)))))) + +(defn- apply-smooth-neighbour + "Adds smooth handles around one matching node." + [content point tangent-data {:keys [index prev-p next-p next-i]}] + (let [curr-command (:command (get content index)) + next-command (:command (get content next-i)) + prev-h (smooth-handle point tangent-data prev-p) + next-h (smooth-handle point tangent-data next-p)] + (cond-> content + (and (= :line-to curr-command) (some? prev-p)) + (update index helpers/update-curve-to prev-p prev-h) + + (and (= :line-to next-command) (some? next-p)) + (update next-i helpers/update-curve-to next-h next-p) + + (and (= :curve-to curr-command) (some? prev-p)) + (update index update-handler :c2 prev-h) + + (and (= :curve-to next-command) (some? next-p)) + (update next-i update-handler :c1 next-h)))) + +(defn- corner-handle + [point neighbour] + (gpt/add point (gpt/scale (gpt/to-vec point neighbour) (/ 1 3)))) + +(defn- apply-corner-neighbour + "Adds independent handles around one matching node." + [content point {:keys [index segment prev-p next-c next-i next-p]}] + (cond-> content + (and (= :line-to (:command segment)) (some? prev-p)) + (update index helpers/update-curve-to prev-p (corner-handle point prev-p)) + + (and (= :curve-to (:command segment)) (some? prev-p)) + (update index update-handler :c2 (corner-handle point prev-p)) + + (and (= :line-to (:command next-c)) (some? next-p)) + (update next-i helpers/update-curve-to (corner-handle point next-p) next-p) + + (and (= :curve-to (:command next-c)) (some? next-p)) + (update next-i update-handler :c1 (corner-handle point next-p)))) + (defn make-curve-point - "Changes the content to make the point a 'curve'. The handlers will be - positioned in the same vector that results from the previous->next - points but with fixed length; return a plain segments vector" + "Adds curve handles to every node at `point`." [content point] - - (let [;; We perform this operation before because it can be - ;; optimized with internal reduction so is better to use the - ;; PathData type before converting it to plain vector. - indices - (point-indices content point) - - ;; We transform content to a plain format for execute the - ;; algorithm because right now is the only way to execute it - content - (vec content) - - vectors - (map (fn [index] - (let [segment (get content index) - prev-i (dec index) - prev (when (not (= :move-to (:command segment))) - (get content prev-i)) - next-i (inc index) - next (get content next-i) - next (when (not (= :move-to (:command next))) - next)] - {:index index - :prev-i (when (some? prev) prev-i) - :prev-c prev - :prev-p (helpers/segment->point prev) - :next-i (when (some? next) next-i) - :next-c next - :next-p (helpers/segment->point next) - :segment segment})) - indices) - - points - (into #{} xf:mapcat-points vectors)] - - (if (= (count points) 2) - (let [[fpoint spoint] (vec points) - v1 (gpt/to-vec fpoint point) - v2 (gpt/to-vec fpoint spoint) - vp (gpt/project v1 v2) - vh (gpt/subtract v1 vp) - - add-curve - (fn [content {:keys [index prev-p next-p next-i]}] - (let [curr-segment (get content index) - curr-command (get curr-segment :command) - - next-segment (get content next-i) - next-command (get next-segment :command) - - ;; New handlers for prev-point and next-point - prev-h - (when (some? prev-p) (gpt/add prev-p vh)) - - next-h - (when (some? next-p) (gpt/add next-p vh)) - - ;; Correct 1/3 to the point improves the curve - prev-correction - (when (some? prev-h) (gpt/scale (gpt/to-vec prev-h point) (/ 1 3))) - - next-correction - (when (some? next-h) (gpt/scale (gpt/to-vec next-h point) (/ 1 3))) - - prev-h - (when (some? prev-h) (gpt/add prev-h prev-correction)) - - next-h - (when (some? next-h) (gpt/add next-h next-correction))] - - (cond-> content - (and (= :line-to curr-command) (some? prev-p)) - (update index helpers/update-curve-to prev-p prev-h) - - (and (= :line-to next-command) (some? next-p)) - (update next-i helpers/update-curve-to next-h next-p) - - (and (= :curve-to curr-command) (some? prev-p)) - (update index update-handler :c2 prev-h) - - (and (= :curve-to next-command) (some? next-p)) - (update next-i update-handler :c1 next-h))))] - - (reduce add-curve content vectors)) - - (let [add-curve - (fn [content {:keys [index segment prev-p next-c next-i]}] - (cond-> content - (= :line-to (:command segment)) - (update index #(line->curve prev-p %)) - - (= :curve-to (:command segment)) - (update index #(line->curve prev-p %)) - - (= :line-to (:command next-c)) - (update next-i #(line->curve point %)) - - (= :curve-to (:command next-c)) - (update next-i #(line->curve point %))))] - (reduce add-curve content vectors))))) + (let [indices (vec (point-indices content point)) + content (vec content) + neighbourhoods (mapv #(curve-neighbourhood content %) indices) + neighbour-points (into #{} xf:mapcat-points neighbourhoods)] + (if (= (count neighbour-points) 2) + (let [tangent-data (smooth-tangent + content point indices neighbourhoods neighbour-points)] + (reduce #(apply-smooth-neighbour %1 point tangent-data %2) + content + neighbourhoods)) + (reduce #(apply-corner-neighbour %1 point %2) content neighbourhoods)))) (defn get-segments-with-points "Given a content and a set of points return all the segments in the path @@ -528,6 +550,103 @@ (into [] (mapcat process-segments) (d/enumerate content)))) +(defn collapse-handler + "Collapses a handler onto its node and simplifies flat curves to lines." + [content index prefix] + (let [content (vec content) + node (handler->node content index prefix) + [cx cy] (helpers/prefix->coords prefix)] + (if (and (some? node) + (= :curve-to (dm/get-in content [index :command]))) + (impl/from-plain + (-> content + (assoc-in [index :params cx] (:x node)) + (assoc-in [index :params cy] (:y node)) + (remove-line-curves))) + (impl/from-plain content)))) + +(def ^:private curve-toggle-bow + "Perpendicular handle offset used when curving a line." + 0.25) + +(defn toggle-segment-curve + "Toggles a segment between a line and a bowed curve." + [content index] + (let [content (vec content) + segment (get content index) + from (helpers/segment->point (get content (dec index))) + to (helpers/segment->point segment)] + (impl/from-plain + (case (:command segment) + :line-to + (if (some? from) + (let [v (gpt/to-vec from to) + perp (gpt/scale (gpt/point (- (:y v)) (:x v)) curve-toggle-bow) + h1 (-> from (gpt/add (gpt/scale v (/ 1 3))) (gpt/add perp)) + h2 (-> from (gpt/add (gpt/scale v (/ 2 3))) (gpt/add perp))] + (update content index helpers/update-curve-to h1 h2)) + content) + + :curve-to + (assoc content index {:command :line-to + :params (select-keys (:params segment) [:x :y])}) + + content)))) + +(defn- subpath-start-indices + "Returns the starting command index for every command in `content`." + [content] + (loop [i 0 + start 0 + result (transient [])] + (if (>= i (count content)) + (persistent! result) + (let [start (if (= :move-to (:command (nth content i))) i start)] + (recur (inc i) start (conj! result start)))))) + +(defn remove-segments + "Removes segments and opens their subpaths. Closing segments become + lines when needed to preserve geometry." + [content indices] + (let [content (vec content) + indices (set indices) + starts (subpath-start-indices content) + + broken (into #{} (keep #(nth starts % nil)) indices) + + content + (into [] + (comp + (map-indexed + (fn [i cmd] + (cond + (contains? indices i) + (when-not (= :close-path (:command cmd)) + {:command :move-to + :params (select-keys (:params cmd) [:x :y])}) + + ;; Preserve the closing edge of broken subpaths. + (and (= :close-path (:command cmd)) + (contains? broken (nth starts i))) + {:command :line-to + :params (-> (nth content (nth starts i)) + (get :params) + (select-keys [:x :y]))} + + :else cmd))) + (remove nil?)) + content) + + subpaths + (reduce (fn [acc cmd] + (if (or (= :move-to (:command cmd)) (empty? acc)) + (conj acc [cmd]) + (update acc (dec (count acc)) conj cmd))) + [] + content)] + (impl/from-plain + (into [] (comp (filter #(> (count %) 1)) cat) subpaths)))) + ;; FIXME: rename to next-segment (defn next-node "Calculates the next-node to be inserted." @@ -543,78 +662,237 @@ :params (helpers/make-curve-params position prev-handler)} :else {:command :move-to :params position}))) -(defn remove-nodes - "Removes from content the points given. Will try to reconstruct the paths - to keep everything consistent" - [content points] +(def ^:private ^:const chain-samples-per-segment 8) +(defn- chain-samples + "Returns ordered samples along a segment chain." + [chain] + (into [(:start (first chain))] + (mapcat + (fn [{:keys [start end segment]}] + (let [ts (map #(/ (double %) chain-samples-per-segment) + (range 1 (inc chain-samples-per-segment)))] + (if (= :curve-to (:command segment)) + (let [curve (helpers/command->bezier segment start)] + (map #(helpers/curve-values curve %) ts)) + (map #(helpers/line-values [start end] %) ts))))) + chain)) + +(defn- chain-tangent + "Returns an inward unit tangent at one end of a chain." + [{:keys [start end segment]} at-start? origin samples] + (let [tangent + (if (= :curve-to (:command segment)) + (let [curve (helpers/command->bezier segment start)] + (cond-> (helpers/curve-tangent curve (if at-start? 0 1)) + (not at-start?) (gpt/negate))) + (if at-start? + (gpt/to-vec start end) + (gpt/to-vec end start))) + tangent (gpt/unit tangent)] + (if (gpt/almost-zero? tangent) + (->> samples + (map #(gpt/to-vec origin %)) + (remove gpt/almost-zero?) + (map gpt/unit) + (first)) + tangent))) + +(defn- flat-chain? + "True when a sampled chain is nearly straight." + [start end samples] + (or (mth/almost-zero? (gpt/distance start end)) + (every? #(< (gpt/point-line-distance % start end) 0.01) samples))) + +(defn- restore-split-curve + "Rejoins two untouched De Casteljau pieces into one cubic." + [chain] + (when (= 2 (count chain)) + (let [{left-segment :segment left-start :start} (first chain) + {right-segment :segment} (second chain)] + (when (and (= :curve-to (:command left-segment)) + (= :curve-to (:command right-segment))) + (let [[start split left-h1 left-h2 :as left-curve] + (helpers/command->bezier left-segment left-start) + [_ end right-h1 right-h2 :as right-curve] + (helpers/command->bezier right-segment split) + left-length (gpt/distance left-h2 split) + right-length (gpt/distance split right-h1)] + (when (and (not (mth/almost-zero? left-length)) + (not (mth/almost-zero? right-length))) + (let [t (/ left-length (+ left-length right-length)) + original-h1 (-> (gpt/to-vec start left-h1) + (gpt/scale (/ 1.0 t)) + (gpt/add start)) + original-h2 (-> (gpt/to-vec end right-h2) + (gpt/scale (/ 1.0 (- 1.0 t))) + (gpt/add end)) + candidate [start end original-h1 original-h2] + [left' right'] (helpers/curve-split candidate t)] + (when (every? true? + (map gpt/close? + (concat left-curve right-curve) + (concat left' right'))) + (helpers/make-curve-to end original-h1 original-h2))))))))) + +(defn- approximate-chain + "Replaces a segment chain with a line or fitted curve." + [chain] + (or (restore-split-curve chain) + (let [start (:start (first chain)) + end (:end (peek chain)) + samples (chain-samples chain) + tan1 (chain-tangent (first chain) true start (rest samples)) + tan2 (chain-tangent (peek chain) false end (rest (rseq samples)))] + (if (or (flat-chain? start end samples) + (nil? tan1) + (nil? tan2)) + (helpers/make-line-to end) + (let [[h1 h2] (fit/fit-cubic samples tan1 tan2)] + (helpers/make-curve-to end h1 h2)))))) + +(defn- split-content-subpaths + "Splits plain path commands into subpath command vectors." + [content] + (reduce + (fn [subpaths segment] + (if (= :move-to (:command segment)) + (conj subpaths [segment]) + (if (seq subpaths) + (update subpaths (dec (count subpaths)) conj segment) + subpaths))) + [] + content)) + +(defn- removed-point-joins-subpaths? + "True when a removed point is an endpoint shared by open subpaths." + [subpaths points] + (let [open-endpoints + (keep (fn [subpath] + (let [start (some-> subpath first helpers/segment->point) + end (some-> subpath peek helpers/segment->point)] + (when (and (some? start) + (some? end) + (not (subpath/pt= start end))) + #{start end}))) + subpaths)] + (some (fn [point] + (< 1 (count (filter (fn [endpoints] + (some #(subpath/pt= point %) endpoints)) + open-endpoints)))) + points))) + +(defn- rotate-removed-closed-start + "Rotates a closed subpath so a removed seam becomes an interior node." + [subpath points] + (let [subpath (vec subpath) + close? (= :close-path (:command (peek subpath))) + body (cond-> subpath close? pop) + start (some-> body first helpers/segment->point) + end (some-> body peek helpers/segment->point) + closed? (or close? (= start end))] + (if-not (and closed? (contains? points start)) + subpath + (let [segments (subvec body 1) + ;; Materialize an implicit close segment before rotating. + segments (cond-> segments + (and close? (not= start end)) + (conj (helpers/make-line-to start))) + new-start-index + (first + (keep-indexed + (fn [index segment] + (when-not (contains? points (helpers/segment->point segment)) + index)) + segments))] + (if (nil? new-start-index) + [] + (let [new-start (helpers/segment->point + (nth segments new-start-index)) + rotated (into [] + (concat + (subvec segments (inc new-start-index)) + (subvec segments 0 (inc new-start-index))))] + (cond-> (into [(helpers/make-move-to new-start)] rotated) + close? (conj {:command :close-path :params {}})))))))) + +(defn- remove-nodes* + "Removes interior nodes from prepared content." + [content points] + (loop [result [] + pending [] + subpath-start nil + prev-point nil + segments (seq content)] + + (if (nil? segments) + ;; Drop subpaths left with only a start point. + (into [] (comp (filter #(> (count %) 1)) cat) result) + + (let [segment (first segments) + move? (= :move-to (:command segment)) + close? (= :close-path (:command segment)) + point (if close? subpath-start (helpers/segment->point segment)) + remove? (and (not close?) (contains? points point)) + + ;; Start a result subpath for each move command. + result (if move? (conj result []) result) + head (dec (count result)) + subpath (peek result) + + [result pending] + (cond + ;; Collect removed interior nodes until the next kept node. + remove? + [result (if (seq subpath) + (conj pending {:start prev-point :end point :segment segment}) + [])] + + move? + [(update result head conj segment) []] + + ;; Promote the first kept node to the subpath start. + (empty? subpath) + [(update result head conj (helpers/make-move-to point)) []] + + (seq pending) + (if (and close? (contains? points subpath-start)) + ;; Close straight onto the new start. + [(update result head conj segment) []] + (let [chain (conj pending {:start prev-point :end point :segment segment}) + approx (approximate-chain chain) + ;; The close command already draws a zero-length replacement. + skip? (and close? + (= :line-to (:command approx)) + (< (gpt/distance (:start (first chain)) point) 0.01)) + result (cond-> result + (not skip?) (update head conj approx) + close? (update head conj segment))] + [result []])) + + :else + [(update result head conj segment) []])] + + (recur result + pending + (if move? point subpath-start) + point + (next segments)))))) + +(defn remove-nodes + "Removes nodes and joins surrounding segments with a fitted replacement." + [content points] (if (empty? points) content - - (let [content (d/with-prev content)] - - (loop [result [] - last-handler nil - [cur-segment prev-segment] (first content) - content (rest content)] - - (if (nil? cur-segment) - ;; The result with be an array of arrays were every entry is a subpath - (->> result - ;; remove empty and only 1 node subpaths - (filter #(> (count %) 1)) - ;; flatten array-of-arrays plain array - (flatten) - (into [])) - - (let [move? (= :move-to (:command cur-segment)) - curve? (= :curve-to (:command cur-segment)) - - ;; When the old command was a move we start a subpath - result (if move? (conj result []) result) - - subpath (peek result) - - point (helpers/segment->point cur-segment) - - old-prev-point (helpers/segment->point prev-segment) - new-prev-point (helpers/segment->point (peek subpath)) - - remove? (contains? points point) - - - ;; We store the first handler for the first curve to be removed to - ;; use it for the first handler of the regenerated path - cur-handler (cond - (and (not last-handler) remove? curve?) - (select-keys (:params cur-segment) [:c1x :c1y]) - - (not remove?) - nil - - :else - last-handler) - - cur-segment (cond-> cur-segment - ;; If we're starting a subpath and it's not a move make it a move - (and (not move?) (empty? subpath)) - (assoc :command :move-to - :params (select-keys (:params cur-segment) [:x :y])) - - ;; If have a curve the first handler will be relative to the previous - ;; point. We change the handler to the new previous point - (and curve? (seq subpath) (not= old-prev-point new-prev-point)) - (update :params merge last-handler)) - - head-idx (dec (count result)) - - result (cond-> result - (not remove?) - (update head-idx conj cur-segment))] - (recur result - cur-handler - (first content) - (rest content)))))))) + (let [subpaths (split-content-subpaths content) + content (if (removed-point-joins-subpaths? subpaths points) + (subpath/close-subpaths content) + content) + content (into [] + (mapcat #(rotate-removed-closed-start % points)) + (split-content-subpaths + content))] + (remove-nodes* content points)))) (defn join-nodes "Creates new segments between points that weren't previously. @@ -649,41 +927,119 @@ (into content new-content))) +(def ^:private separate-node-offset (gpt/point 8 8)) + +(defn- separate-node + "Splits a node into offset open ends, preserving adjacent handles." + [content point offset] + (let [content (vec content) + n (count content) + {ox :x oy :y} offset + seg? (fn [c] (and (some? c) + (not= :move-to (:command c)) + (not= :close-path (:command c))))] + (loop [i 0 + k 0 + result (transient [])] + (if (>= i n) + (persistent! result) + (let [cmd (nth content i) + nxt (nth content (inc i) nil) + at-p? (and (not= :close-path (:command cmd)) + (gpt/close? point (helpers/segment->point cmd)))] + (cond + ;; Offset a subpath start. + (and at-p? (= :move-to (:command cmd))) + (let [off (gpt/point (* k ox) (* k oy))] + (recur (inc i) (inc k) + (conj! result (-> cmd + (update-in [:params :x] + (:x off)) + (update-in [:params :y] + (:y off)))))) + + ;; Split an interior node into two subpaths. + (and at-p? (seg? cmd) (seg? nxt)) + (let [off (gpt/point (* k ox) (* k oy)) + cmd' (cond-> (-> cmd + (update-in [:params :x] + (:x off)) + (update-in [:params :y] + (:y off))) + (= :curve-to (:command cmd)) + (-> (update-in [:params :c2x] + (:x off)) + (update-in [:params :c2y] + (:y off)))) + k2 (inc k) + off2 (gpt/point (* k2 ox) (* k2 oy)) + mv (helpers/make-move-to (gpt/add point off2)) + nxt' (cond-> nxt + (= :curve-to (:command nxt)) + (-> (update-in [:params :c1x] + (:x off2)) + (update-in [:params :c1y] + (:y off2))))] + (recur (+ i 2) (inc k2) + (-> result (conj! cmd') (conj! mv) (conj! nxt')))) + + ;; Open and offset a closed seam. + (and at-p? (seg? cmd) (= :close-path (:command nxt))) + (let [off (gpt/point (* k ox) (* k oy)) + cmd' (cond-> (-> cmd + (update-in [:params :x] + (:x off)) + (update-in [:params :y] + (:y off))) + (= :curve-to (:command cmd)) + (-> (update-in [:params :c2x] + (:x off)) + (update-in [:params :c2y] + (:y off))))] + ;; Drop the close command so the seam stays open. + (recur (+ i 2) (inc k) (conj! result cmd'))) + + ;; Offset the end of an open subpath. + (and at-p? (seg? cmd) (not= :close-path (:command nxt))) + (let [off (gpt/point (* k ox) (* k oy))] + (recur (inc i) (inc k) + (conj! result (cond-> (-> cmd + (update-in [:params :x] + (:x off)) + (update-in [:params :y] + (:y off))) + (= :curve-to (:command cmd)) + (-> (update-in [:params :c2x] + (:x off)) + (update-in [:params :c2y] + (:y off))))))) + + :else + (recur (inc i) k (conj! result cmd)))))))) + (defn separate-nodes - "Removes the segments between the points given" - [content points] + "Removes segments between points or splits one node into offset open ends." + ([content points] + (separate-nodes content points separate-node-offset)) + ([content points offset] + (if (= 1 (count points)) + (separate-node (vec content) (first points) offset) - (let [content (d/with-prev content)] - (loop [result [] - [cur-segment prev-segment] (first content) - content (rest content)] + (let [content (d/with-prev content)] + (loop [result [] + [cur-segment prev-segment] (first content) + content (rest content)] - (if (nil? cur-segment) - (->> result - (filter #(> (count %) 1)) - (flatten) - (into [])) + (if (nil? cur-segment) + (->> result + (filter #(> (count %) 1)) + (flatten) + (into [])) - (let [prev-point (helpers/segment->point prev-segment) - cur-point (helpers/segment->point cur-segment) + (let [prev-point (helpers/segment->point prev-segment) + cur-point (helpers/segment->point cur-segment) - cur-segment (cond-> cur-segment - (and (contains? points prev-point) - (contains? points cur-point)) + cur-segment (cond-> cur-segment + (and (contains? points prev-point) + (contains? points cur-point)) - (assoc :command :move-to - :params (select-keys (:params cur-segment) [:x :y]))) + (assoc :command :move-to + :params (select-keys (:params cur-segment) [:x :y]))) - move? (= :move-to (:command cur-segment)) + move? (= :move-to (:command cur-segment)) - result (if move? (conj result []) result) - head-idx (dec (count result)) + result (if move? (conj result []) result) + head-idx (dec (count result)) - result (-> result - (update head-idx conj cur-segment))] - (recur result - (first content) - (rest content))))))) + result (-> result + (update head-idx conj cur-segment))] + (recur result + (first content) + (rest content))))))))) (defn- add-to-set @@ -753,9 +1109,10 @@ (mapv replace-command)))) (defn merge-nodes - "Reduces the contiguous segments in points to a single point" + "Joins and merges `points` into one point." [content points] - (let [segments (get-segments-with-points content points)] + (let [content (join-nodes content points) + segments (get-segments-with-points content points)] (if (seq segments) (let [point->merge-point (-> segments (group-segments) @@ -889,3 +1246,16 @@ (conj result {:command :close-path}) result)] (impl/from-plain result)))))) + +(defn smooth-points->content + "Fits smooth path content through `points`, falling back to lines." + [points tolerance] + (let [curves (when (>= (count points) 3) + (fit/fit-curve points tolerance))] + (if (empty? curves) + (points->content points) + (impl/from-plain + (into [(helpers/make-move-to (ffirst curves))] + (map (fn [[_ end h1 h2]] + (helpers/make-curve-to end h1 h2))) + curves))))) diff --git a/common/src/app/common/types/path/selection.cljc b/common/src/app/common/types/path/selection.cljc new file mode 100644 index 0000000000..826047431b --- /dev/null +++ b/common/src/app/common/types/path/selection.cljc @@ -0,0 +1,213 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.common.types.path.selection + "Transforms selected path nodes and handlers." + (:require + [app.common.data :as d] + [app.common.geom.point :as gpt] + [app.common.math :as mth] + [app.common.types.path.helpers :as helpers] + [app.common.types.path.impl :as impl])) + +(def align-nodes-axis + "Valid alignment axes." + #{:hleft :hcenter :hright :vtop :vcenter :vbottom}) + +(def distribute-nodes-axis + "Valid distribution axes." + #{:horizontal :vertical}) + +(defn- selected-node-entries + "Returns selected nodes as `[index point]` pairs." + [content indices] + (into [] + (comp (filter (fn [[i seg]] + (and (contains? indices i) + (not= :close-path (:command seg))))) + (map (fn [[i seg]] [i (helpers/segment->point seg)]))) + (d/enumerate content))) + +(defn- expand-coincident-node-indices + "Includes every command that represents a selected logical node." + [content indices] + (let [indices (set indices) + points (into #{} (map second) (selected-node-entries content indices))] + (into indices + (comp + (filter (fn [[_ segment]] + (and (not= :close-path (:command segment)) + (some #(gpt/close? % (helpers/segment->point segment)) points)))) + (map first)) + (d/enumerate content)))) + +(defn- translate-nodes + "Moves selected nodes and handlers by their node deltas." + [content indices deltas] + (let [node-sel? (fn [i] (contains? indices i)) + add-delta (fn [params xk yk delta] + (if (and delta (contains? params xk)) + (-> params + (update xk + (:x delta)) + (update yk + (:y delta))) + params)) + move-cmd (fn [i {:keys [command params] :as seg}] + (let [curve? (= :curve-to command) + params (cond-> params + (and (not= :close-path command) (node-sel? i)) + (add-delta :x :y (get deltas i)) + + (and curve? (node-sel? (dec i))) + (add-delta :c1x :c1y (get deltas (dec i))) + + (and curve? (node-sel? i)) + (add-delta :c2x :c2y (get deltas i)))] + (assoc seg :params params)))] + (into [] (map-indexed move-cmd) content))) + +(defn flip-content + "Flips selected nodes and handlers across their bounds." + [content indices axis] + (let [content (vec content) + indices (set indices) + node-sel? (fn [i] (contains? indices i)) + positions (into [] + (comp (filter (fn [[i seg]] + (and (node-sel? i) + (not= :close-path (:command seg))))) + (map (fn [[_ seg]] (helpers/segment->point seg)))) + (d/enumerate content))] + (if (empty? positions) + (impl/from-plain content) + (let [xs (map :x positions) + ys (map :y positions) + cx (/ (+ (reduce min xs) (reduce max xs)) 2.0) + cy (/ (+ (reduce min ys) (reduce max ys)) 2.0) + flip-x? (= axis :horizontal) + reflect (fn [params xk yk] + (if flip-x? + (cond-> params + (contains? params xk) (update xk #(- (* 2.0 cx) %))) + (cond-> params + (contains? params yk) (update yk #(- (* 2.0 cy) %))))) + flip-cmd (fn [i {:keys [command params] :as seg}] + (let [curve? (= :curve-to command) + params (cond-> params + (and (not= :close-path command) (node-sel? i)) + (reflect :x :y) + + (and curve? (node-sel? (dec i))) + (reflect :c1x :c1y) + + (and curve? (node-sel? i)) + (reflect :c2x :c2y))] + (assoc seg :params params)))] + (impl/from-plain + (into [] (map-indexed flip-cmd) content)))))) + +(defn align-content + "Aligns two or more selected nodes within their bounds." + [content indices axis] + (let [content (vec content) + indices (set indices) + entries (selected-node-entries content indices)] + (if (< (count entries) 2) + (impl/from-plain content) + (let [pts (map second entries) + xs (map :x pts) + ys (map :y pts) + minx (reduce min xs) + maxx (reduce max xs) + miny (reduce min ys) + maxy (reduce max ys) + [coord target] (case axis + :hleft [:x minx] + :hcenter [:x (/ (+ minx maxx) 2.0)] + :hright [:x maxx] + :vtop [:y miny] + :vcenter [:y (/ (+ miny maxy) 2.0)] + :vbottom [:y maxy]) + deltas (into {} + (map (fn [[i p]] + [i (if (= coord :x) + (gpt/point (- target (:x p)) 0) + (gpt/point 0 (- target (:y p))))])) + entries)] + (impl/from-plain (translate-nodes content indices deltas)))))) + +(defn set-nodes-coordinate + "Sets one coordinate of selected nodes and handlers." + [content indices axis value] + (let [content (vec content) + indices (expand-coincident-node-indices content indices) + entries (selected-node-entries content indices) + deltas (into {} + (map (fn [[i p]] + [i (if (= axis :x) + (gpt/point (- value (:x p)) 0) + (gpt/point 0 (- value (:y p))))])) + entries)] + (impl/from-plain (translate-nodes content indices deltas)))) + +(defn set-handler-points + "Moves handlers to their target points." + [content pts] + (impl/from-plain + (reduce + (fn [content [[index prefix] pt]] + (if (= :curve-to (:command (get content index))) + (let [[cx cy] (if (= prefix :c1) [:c1x :c1y] [:c2x :c2y])] + (-> content + (assoc-in [index :params cx] (:x pt)) + (assoc-in [index :params cy] (:y pt)))) + content)) + (vec content) + pts))) + +(defn translate-selected-nodes + "Moves selected nodes and handlers by `delta`." + [content indices delta] + (let [content (vec content) + indices (expand-coincident-node-indices content indices)] + (impl/from-plain + (translate-nodes content indices (into {} (map (fn [i] [i delta])) indices))))) + +(defn distribute-content + "Distributes three or more selected positions along `axis`." + [content indices axis] + (let [content (vec content) + indices (set indices) + entries (selected-node-entries content indices) + index->point (into {} entries) + horizontal? (= axis :horizontal) + coord (fn [p] (if horizontal? (:x p) (:y p))) + groups (->> entries + (group-by (fn [[_ p]] [(mth/round (:x p) 0.1) (mth/round (:y p) 0.1)])) + (mapv (fn [[_ es]] + {:point (second (first es)) + :indices (mapv first es)}))) + sorted (sort-by (comp coord :point) groups)] + (if (< (count groups) 3) + (impl/from-plain content) + (let [lo (coord (:point (first sorted))) + hi (coord (:point (last sorted))) + step (/ (- hi lo) (dec (count sorted))) + deltas (into {} + (comp + (map-indexed + (fn [k {:keys [indices]}] + (let [target (+ lo (* k step))] + (map (fn [i] + (let [node-point (get index->point i) + d (- target (coord node-point)) + dp (if horizontal? + (gpt/point d 0) + (gpt/point 0 d))] + [i dp])) + indices)))) + cat) + sorted)] + (impl/from-plain (translate-nodes content indices deltas)))))) diff --git a/common/src/app/common/types/path/shape_to_path.cljc b/common/src/app/common/types/path/shape_to_path.cljc index ffd8140adf..665eb592fe 100644 --- a/common/src/app/common/types/path/shape_to_path.cljc +++ b/common/src/app/common/types/path/shape_to_path.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.path.shape-to-path (:require diff --git a/common/src/app/common/types/path/subpath.cljc b/common/src/app/common/types/path/subpath.cljc index 50c0055466..95e16664f2 100644 --- a/common/src/app/common/types/path/subpath.cljc +++ b/common/src/app/common/types/path/subpath.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.path.subpath (:require @@ -28,13 +28,17 @@ (defn add-subpath-command "Adds a command to the subpath" [subpath command] - (let [command (if (= :close-path (:command command)) - (helpers/make-line-to (:from subpath)) - command) - p (helpers/segment->point command)] - (-> subpath - (assoc :to p) - (update :data conj command)))) + (let [close? (= :close-path (:command command))] + (if (and close? (pt= (:from subpath) (:to subpath))) + ;; Avoid adding a duplicate node at an already closed seam. + subpath + (let [command (if close? + (helpers/make-line-to (:from subpath)) + command) + p (helpers/segment->point command)] + (-> subpath + (assoc :to p) + (update :data conj command)))))) (defn reverse-command "Reverses a single command" @@ -189,6 +193,27 @@ (into [] xf-mapcat-data closed-subpaths))) +(defn- close-loop + "Adds an explicit close command when a subpath's endpoints meet." + [{:keys [from to data] :as subpath}] + (let [last-seg (peek data)] + (if (or (< (count data) 2) + (= :close-path (:command last-seg)) + (not (pt= from to))) + subpath + (let [data (cond-> data + (= :line-to (:command last-seg)) (pop))] + (assoc subpath + :to from + :data (conj data {:command :close-path :params {}})))))) + +(defn close-loops + "Adds close commands to subpaths whose endpoints meet." + [content] + (->> (get-subpaths content) + (mapv close-loop) + (into [] xf-mapcat-data))) + ;; FIXME: revisit this fn impl for perfromance (defn reverse-content "Given a content reverse the order of the commands" diff --git a/common/src/app/common/types/plugins.cljc b/common/src/app/common/types/plugins.cljc index 7fe8a4c7d4..bb74bfcc09 100644 --- a/common/src/app/common/types/plugins.cljc +++ b/common/src/app/common/types/plugins.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.plugins (:require @@ -27,6 +27,20 @@ schema:string schema:string]]) +(def valid-permissions + "Set of valid plugin permissions that can be granted to plugins." + #{"content:read" "content:write" + "library:read" "library:write" + "comment:read" "comment:write" + "clipboard:read" "clipboard:write" + "user:read" + "allow:downloads" + "allow:localstorage"}) + +(def schema:permissions + "Schema for plugin permissions - a set of valid permission strings." + [:set {:gen/max 11} (into [:enum] (sort valid-permissions))]) + (def schema:registry-entry [:map [:plugin-id :string] @@ -36,7 +50,7 @@ [:host :string] [:code :string] [:icon {:optional true} :string] - [:permissions [:set :string]]]) + [:permissions schema:permissions]]) (def schema:plugin-registry [:map diff --git a/common/src/app/common/types/profile.cljc b/common/src/app/common/types/profile.cljc index 742e2dd1c0..9a02d3eb0c 100644 --- a/common/src/app/common/types/profile.cljc +++ b/common/src/app/common/types/profile.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.profile (:require diff --git a/common/src/app/common/types/project.cljc b/common/src/app/common/types/project.cljc index 0b20e2c232..f6f20ea36a 100644 --- a/common/src/app/common/types/project.cljc +++ b/common/src/app/common/types/project.cljc @@ -3,7 +3,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.project (:require diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index d645e72ae1..6a617c21cc 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape (:require @@ -233,7 +233,50 @@ [:grow-type {:optional true} [::sm/one-of grow-types]] [:applied-tokens {:optional true} cto/schema:applied-tokens] - [:plugin-data {:optional true} ctpg/schema:plugin-data]]) + [:plugin-data {:optional true} ctpg/schema:plugin-data] + + ;; `rotation`, `flip-x` and `flip-y` are fields of the `Shape` record (see + ;; `cr/defrecord Shape` above) and this schema did not declare them. + ;; `rotation` was already named in `allowed-shape-attrs` here and in + ;; `app.common.types.shape.attrs/editable-attrs`, so the omission was in this + ;; schema and not in the model. Anything reading the model from the schema + ;; rather than from a live shape missed all three: the graph projection + ;; derives one column per entry (`app.graph.schema.projection`), so shape + ;; nodes carried no rotation at all, and a consumer cannot place a shape + ;; without it. + ;; + ;; Nilable, because `app.common.record/defrecord` cannot remove a base + ;; field: its `without` assocs nil and its `containsKey` answers true + ;; whatever the field holds, so nil is how a record field says "unset". + ;; `flip-x` and `flip-y` are nil on every shape `setup-shape` builds, since + ;; `make-minimal-shape` gives them no default. + ;; + ;; Optional as well, unlike the geometry group below, because this schema + ;; has a second job: `check-shape-generic-attrs` validates partial update + ;; payloads with it, such as the `{:blocked true}` that + ;; `app.main.data.workspace/update-shape` passes. A required key here would + ;; reject every such payload. + [:rotation {:optional true} [:maybe ::sm/safe-number]] + [:flip-x {:optional true} [:maybe :boolean]] + [:flip-y {:optional true} [:maybe :boolean]] + + ;; Carried on circles, rects and texts too, not only on frames, so it + ;; belongs here rather than in `schema:frame-attrs`. Not nilable: the key + ;; lives outside the record, `app.common.logic.shapes` dissocs it to unset + ;; it, and `setup-shape` drops it when a caller passes nil. + [:hide-in-viewer {:optional true} :boolean] + + ;; The SVG provenance an import leaves on a shape. Typed `:map` rather than + ;; more precisely on purpose: legacy files hold `svg-transform` as a plain + ;; `{:a … :f}` map rather than a `::gmt/matrix` record, and `svg-viewbox` as + ;; either a `::grc/rect` record or a plain map, so a tighter schema here + ;; would reject files that are otherwise valid. The graph *column* types are + ;; tightened separately, where a wrong guess costs a column rather than a + ;; rejected file (`app.graph.schema.contract/type-overrides`). + [:svg-attrs {:optional true} :map] + [:svg-defs {:optional true} :map] + [:svg-transform {:optional true} :map] + [:svg-viewbox {:optional true} :map]]) (def schema:group-attrs [:map {:title "GroupAttrs"} @@ -244,7 +287,30 @@ [:shapes [:vector {:gen/max 10 :gen/min 1} ::sm/uuid]] [:hide-fill-on-export {:optional true} :boolean] [:show-content {:optional true} :boolean] - [:hide-in-viewer {:optional true} :boolean]]) + ;; `hide-in-viewer` moved to `schema:shape-generic-attrs`: stored files carry + ;; it on circles, rects and texts too, not only on frames. + ;; `use-for-thumbnail` is a frame attribute the model has long had, since + ;; `app.common.files.migrations` renames `:use-for-thumbnail?` to it and + ;; `app.common.logic.libraries` reads it, and this schema had not declared. + [:use-for-thumbnail {:optional true} :boolean]]) + +(def ^:private schema:nilable-geom-attrs + "`schema:shape-geom-attrs`, but nilable. + + Bools and paths are the only two shape types whose geometry can be nil: + `make-minimal-shape` gives `x`, `y`, `width` and `height` a default for every + other type and skips those two, whose extent their content and `selrect` + imply instead. The four keys stay required, because they are `Shape` record + fields and `app.common.record/defrecord` keeps a base field present whatever + it holds. So these two branches cannot merge `schema:shape-geom-attrs`, which + rejects the nil, and declare the same four keys nilable instead. A + schema-derived reader previously saw a bool or a path as having no position or + size at all." + [:map {:title "NilableGeometryAttrs"} + [:x [:maybe ::sm/safe-number]] + [:y [:maybe ::sm/safe-number]] + [:width [:maybe ::sm/safe-number]] + [:height [:maybe ::sm/safe-number]]]) (def ^:private schema:bool-attrs [:map {:title "BoolAttrs"} @@ -253,13 +319,37 @@ [:content path/schema:content]]) (def ^:private schema:rect-attrs - [:map {:title "RectAttrs"}]) + [:map {:title "RectAttrs"} + ;; Legacy radii, set by SVG import (`app.common.files.shapes-builder` parses + ;; `rx`/`ry` off the element) and by migration 0003, which assocs `0`. + ;; Superseded by `r1` to `r4`, but stored files still carry them. Not + ;; nilable: both keys live outside the `Shape` record, so a dissoc removes + ;; them, and `setup-shape` drops a nil before the merge. + [:rx {:optional true} ::sm/safe-number] + [:ry {:optional true} ::sm/safe-number]]) (def ^:private schema:circle-attrs - [:map {:title "CircleAttrs"}]) + [:map {:title "CircleAttrs"} + [:rx {:optional true} ::sm/safe-number] + [:ry {:optional true} ::sm/safe-number]]) (def ^:private schema:svg-raw-attrs - [:map {:title "SvgRawAttrs"}]) + [:map {:title "SvgRawAttrs"} + ;; An svg-raw shape can be a container: importing an SVG builds a + ;; tree of svg-raw shapes, and `cfh/group-like-shape?` treats an + ;; svg-raw with children as group-like. Declaring `:shapes` here + ;; keeps the child ids typed as uuid, so a JSON round trip (binfile + ;; export/import) decodes them back to uuids instead of leaving + ;; strings that no longer resolve against the objects map. + [:shapes {:optional true} [:vector {:gen/max 10} ::sm/uuid]] + ;; The raw SVG node an import kept. + ;; `app.common.files.shapes-builder/create-raw-svg` sets it and + ;; `allowed-svg-attrs` names it. Usually the parsed element, + ;; `{:tag … :attrs … :content …}`, but a bare text node arrives as the + ;; string itself: `<text>hi</text>` becomes one svg-raw for the element + ;; and another for `"hi"`. `app.common.files.shapes-builder/parse-svg-element` + ;; carries a FIXME about exactly that. Both forms are legal and stored. + [:content {:optional true} [:or :map :string]]]) (def schema:image-attrs [:map {:title "ImageAttrs"} @@ -294,7 +384,10 @@ (->> (sg/generator schema:shape-base-attrs) (sg/mcat (fn [{:keys [type] :as shape}] (sg/let [attrs1 (sg/generator schema:shape-generic-attrs) - attrs2 (sg/generator schema:shape-geom-attrs) + attrs2 (if (or (= type :path) + (= type :bool)) + (sg/generator schema:nilable-geom-attrs) + (sg/generator schema:shape-geom-attrs)) attrs3 (case type :text (sg/generator schema:text-attrs) :path (sg/generator schema:path-attrs) @@ -305,10 +398,7 @@ :bool (sg/generator schema:bool-attrs) :group (sg/generator schema:group-attrs) :frame (sg/generator schema:frame-attrs))] - (if (or (= type :path) - (= type :bool)) - (merge attrs1 shape attrs3) - (merge attrs1 shape attrs2 attrs3))))) + (merge attrs1 shape attrs2 attrs3)))) (sg/fmap create-shape))) (def schema:shape-attrs @@ -340,6 +430,7 @@ ctsl/schema:layout-child-attrs schema:bool-attrs schema:shape-generic-attrs + schema:nilable-geom-attrs schema:shape-base-attrs]] [:rect @@ -379,6 +470,7 @@ ctsl/schema:layout-child-attrs schema:path-attrs schema:shape-generic-attrs + schema:nilable-geom-attrs schema:shape-base-attrs]] [:text @@ -532,8 +624,9 @@ {:type :path :name "Path" :fills [] + ;; Paths use centered strokes by default. :strokes [{:stroke-style :solid - :stroke-alignment :inner + :stroke-alignment :center :stroke-width 1 :stroke-color clr/black :stroke-opacity 1}]}) @@ -565,10 +658,15 @@ [type] (let [type (if (= type :curve) :path type) attrs (get-minimal-shape type) - attrs (cond-> attrs - (and (not= :path type) - (not= :bool type)) - (-> (assoc :x 0) + attrs (if (or (= :path type) + (= :bool type)) + (-> attrs + (assoc :x nil) + (assoc :y nil) + (assoc :width nil) + (assoc :height nil)) + (-> attrs + (assoc :x 0) (assoc :y 0) (assoc :width 0.01) (assoc :height 0.01))) diff --git a/common/src/app/common/types/shape/attrs.cljc b/common/src/app/common/types/shape/attrs.cljc index 0d76f5afa4..eb8dc69193 100644 --- a/common/src/app/common/types/shape/attrs.cljc +++ b/common/src/app/common/types/shape/attrs.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape.attrs (:require diff --git a/common/src/app/common/types/shape/background_blur.cljc b/common/src/app/common/types/shape/background_blur.cljc index 214629a580..6eb6b7ad38 100644 --- a/common/src/app/common/types/shape/background_blur.cljc +++ b/common/src/app/common/types/shape/background_blur.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape.background-blur (:require diff --git a/common/src/app/common/types/shape/blur.cljc b/common/src/app/common/types/shape/blur.cljc index e0a149d2bc..59decef09e 100644 --- a/common/src/app/common/types/shape/blur.cljc +++ b/common/src/app/common/types/shape/blur.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape.blur (:require diff --git a/common/src/app/common/types/shape/export.cljc b/common/src/app/common/types/shape/export.cljc index babc18157b..5c54f0455b 100644 --- a/common/src/app/common/types/shape/export.cljc +++ b/common/src/app/common/types/shape/export.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape.export (:require diff --git a/frontend/src/app/render_wasm/resources.cljs b/common/src/app/common/types/shape/images.cljs similarity index 87% rename from frontend/src/app/render_wasm/resources.cljs rename to common/src/app/common/types/shape/images.cljs index 9f564aa990..18186fe065 100644 --- a/frontend/src/app/render_wasm/resources.cljs +++ b/common/src/app/common/types/shape/images.cljs @@ -2,14 +2,14 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL -(ns app.render-wasm.resources +(ns app.common.types.shape.images "Host-agnostic enumeration of the external resources a scene needs to render: which image bytes its shapes reference. Pure data walking — no browser or Node dependencies — so the workspace and the headless exporter - derive the same set from the same source (sibling of - `app.render-wasm.fallback-fonts`, which does the same for fonts)." + derive the same set from the same source (counterpart of + `app.common.fonts`, which does the same for fonts)." (:require [app.common.types.fills :as types.fills])) diff --git a/common/src/app/common/types/shape/interactions.cljc b/common/src/app/common/types/shape/interactions.cljc index 6b06b68897..f3e7912717 100644 --- a/common/src/app/common/types/shape/interactions.cljc +++ b/common/src/app/common/types/shape/interactions.cljc @@ -2,14 +2,13 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape.interactions (:require [app.common.data :as d] [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] - [app.common.geom.shapes.bounds :as gsb] [app.common.schema :as sm] [app.common.schema.generators :as sg])) @@ -482,7 +481,13 @@ (if (nil? dest-frame) [(gpt/point 0 0) [:top :left]] - (let [overlay-size (gsb/get-object-bounds objects dest-frame) + (let [;; Use the destination frame selrect (the visible frame box) to compute + ;; the overlay position, not its full object bounds. Bounds include + ;; padding for shadows, blur, strokes and overflowing children, which + ;; would make centered/right/bottom positions off by half that padding + ;; (the visible frame ends up shifted). The viewer reserves the bounds + ;; size and re-aligns the selrect separately (see viewer/calculate-delta). + overlay-size (:selrect dest-frame) base-frame-size (:selrect base-frame) relative-to-shape-size (:selrect relative-to-shape) relative-to-adjusted-to-base-frame {:x (- (:x relative-to-shape-size) (:x base-frame-size)) diff --git a/common/src/app/common/types/shape/layout.cljc b/common/src/app/common/types/shape/layout.cljc index 03532db4ec..b66aabc27d 100644 --- a/common/src/app/common/types/shape/layout.cljc +++ b/common/src/app/common/types/shape/layout.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape.layout (:require diff --git a/common/src/app/common/types/shape/radius.cljc b/common/src/app/common/types/shape/radius.cljc index 34fdd067c9..46dfa2dacb 100644 --- a/common/src/app/common/types/shape/radius.cljc +++ b/common/src/app/common/types/shape/radius.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape.radius (:require diff --git a/common/src/app/common/types/shape/shadow.cljc b/common/src/app/common/types/shape/shadow.cljc index 7ec688ab4a..1398a7b5eb 100644 --- a/common/src/app/common/types/shape/shadow.cljc +++ b/common/src/app/common/types/shape/shadow.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape.shadow (:require diff --git a/common/src/app/common/types/shape/text.cljc b/common/src/app/common/types/shape/text.cljc index 8c0595daa6..122aae80a8 100644 --- a/common/src/app/common/types/shape/text.cljc +++ b/common/src/app/common/types/shape/text.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape.text (:require diff --git a/common/src/app/common/types/shape_tree.cljc b/common/src/app/common/types/shape_tree.cljc index f9ce8ded20..92a889a6d5 100644 --- a/common/src/app/common/types/shape_tree.cljc +++ b/common/src/app/common/types/shape_tree.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape-tree (:require diff --git a/common/src/app/common/types/stroke.cljc b/common/src/app/common/types/stroke.cljc index a3a84f6921..792b56244a 100644 --- a/common/src/app/common/types/stroke.cljc +++ b/common/src/app/common/types/stroke.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.stroke (:require diff --git a/common/src/app/common/types/team.cljc b/common/src/app/common/types/team.cljc index fd099d78db..b515ef9be0 100644 --- a/common/src/app/common/types/team.cljc +++ b/common/src/app/common/types/team.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.team (:require diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc index 6068cfc829..f7aeb37664 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -2,13 +2,14 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.text (:require [app.common.data :as d] [app.common.data.macros :as dm] [app.common.flags :as flags] + [app.common.math :as mth] [app.common.types.color :as clr] [app.common.types.fills :as types.fills] [clojure.set :as set] @@ -217,7 +218,10 @@ attributes or other things that may be attached). - Consider nil values, empty strings or empty lists all equal. - Normalize numeric values (legacy) into strings. - - No value is equal than the default value." + - No value is equal than the default value. + - Numeric attrs (e.g. line-height) compare with float tolerance so + editor/WASM round-trips like \"1.3333333333333333\" vs \"1.33333\" + do not count as a real style change (avoids detaching tokens)." [key value1 value2] (when (text-node-attr? key) (let [default-value (get default-text-attrs key) @@ -229,7 +233,16 @@ $))) value1' (normalize-value value1) value2' (normalize-value value2)] - (not= value1' value2')))) + (cond + (= value1' value2') + false + + :else + (let [n1 (when (string? value1') (d/parse-double value1')) + n2 (when (string? value2') (d/parse-double value2'))] + (if (and (some? n1) (some? n2)) + (not (mth/close? n1 n2)) + true)))))) (defn- compare-text-content "Given two content text structures, conformed by maps and vectors, diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index 10cedd5c19..880149afbb 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.token (:require @@ -423,11 +423,8 @@ :stroke-width :strokes token-attr)) -(defn shape-attr->token-attrs - "Returns the token-attr affected when a given attribute in a shape is changed. - The sub-attr is for attributes that may have multiple values, like strokes - (may be width or color) and layout padding & margin (may have 4 edges)." - ([shape-attr] (shape-attr->token-attrs shape-attr nil)) +(defn- shape-attr->token-attrs* + ([shape-attr] (shape-attr->token-attrs* shape-attr nil)) ([shape-attr changed-sub-attr] (cond (= :fills shape-attr) @@ -468,6 +465,20 @@ (number-keys shape-attr) #{shape-attr} (axis-keys shape-attr) #{shape-attr}))) +(def ^:private shape-attr->token-attrs-1 + (memoize shape-attr->token-attrs*)) + +(defn shape-attr->token-attrs + "Returns the token-attr affected when a given attribute in a shape is changed. + The sub-attr is for attributes that may have multiple values, like strokes + (may be width or color) and layout padding & margin (may have 4 edges)." + ([shape-attr] + (shape-attr->token-attrs-1 shape-attr)) + ([shape-attr changed-sub-attr] + (if (nil? changed-sub-attr) + (shape-attr->token-attrs-1 shape-attr) + (shape-attr->token-attrs* shape-attr changed-sub-attr)))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; HELPERS for token attributes by shape type ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index 2376dd7563..1fe5b87658 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.tokens-lib (:require diff --git a/common/src/app/common/types/tokens_status.cljc b/common/src/app/common/types/tokens_status.cljc new file mode 100644 index 0000000000..c2953cc4c1 --- /dev/null +++ b/common/src/app/common/types/tokens_status.cljc @@ -0,0 +1,147 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.common.types.tokens-status + (:require + #?(:clj [app.common.fressian :as fres]) + #?(:clj [clojure.data.json :as c.json]) + [app.common.schema :as sm] + [app.common.schema.generators :as sg] + [app.common.transit :as t] + [clojure.core.protocols :as cp] + [clojure.datafy :refer [datafy]] + [clojure.pprint :as pp])) + +;; TokensStatus datatype contains the activation status of the themes and sets +;; in a tokens library. + +(defprotocol ITokensStatus + (get-active-theme-ids [_] "Return a clojure set of active theme ids") + (get-active-set-ids [_] "Return a clojure set of active set ids") + (theme-active? [_ theme-id] "Check if a theme is active") + (set-active? [_ set-id] "Check if a set is active") + (set-tokens-status [_ theme-ids set-ids] "Set the activation status of the themes and sets")) + +(deftype TokensStatus [active-theme-ids active-set-ids] + cp/Datafiable + (datafy [_] + {:active-theme-ids active-theme-ids + :active-set-ids active-set-ids}) + + #?@(:clj + [c.json/JSONWriter + (-write [this writter options] + (c.json/-write (datafy this) writter options))]) + + ITokensStatus + (get-active-theme-ids [_] + active-theme-ids) + + (get-active-set-ids [_] + active-set-ids) + + (theme-active? [_ theme-id] + (assert (uuid? theme-id)) + (contains? active-theme-ids theme-id)) + + (set-active? [_ set-id] + (assert (uuid? set-id)) + (contains? active-set-ids set-id)) + + (set-tokens-status [_ theme-ids set-ids] + (assert (set? theme-ids)) + (assert (set? set-ids)) + (TokensStatus. theme-ids set-ids))) + +;; === Helper & Predicate === + +(defn map->TokensStatus + [{:keys [active-theme-ids active-set-ids]}] + (TokensStatus. active-theme-ids active-set-ids)) + +(defn tokens-status? + [o] + (instance? TokensStatus o)) + +;; === Schemas, Check functions & Constructor === + +(declare make-tokens-status) + +(def schema:tokens-status-attrs + [:map {:title "TokensStatus"} + [:active-theme-ids {:optional true} [:set {:gen/max 5} ::sm/uuid]] + [:active-set-ids {:optional true} [:set {:gen/max 5} ::sm/uuid]]]) + +(def schema:tokens-status + [:and {:gen/gen (->> (sg/generator schema:tokens-status-attrs) + (sg/fmap #(make-tokens-status %)))} + [:fn tokens-status?]]) + +(def ^:private check-tokens-status-attrs + (sm/check-fn schema:tokens-status-attrs + :hint "expected valid params for tokens-status")) + +(def check-tokens-status + (sm/check-fn schema:tokens-status + :hint "expected valid tokens-status")) + +(defn make-tokens-status + [& {:as attrs}] + (-> attrs + (update :active-theme-ids #(or % #{})) + (update :active-set-ids #(or % #{})) + (check-tokens-status-attrs) + (map->TokensStatus))) + +;; === Pretty-print for debugging === + +(defmethod pp/simple-dispatch TokensStatus [^TokensStatus obj] + (.write *out* "#penpot/tokens-status ") + (pp/pprint-newline :miser) + (pp/pprint (datafy obj))) + +#?(:clj + (do + (defmethod print-method TokensStatus + [^TokensStatus this ^java.io.Writer w] + (.write w "#penpot/tokens-status ") + (print-method (datafy this) w)) + + (defmethod print-dup TokensStatus + [^TokensStatus this ^java.io.Writer w] + (print-method this w))) + + :cljs + (extend-type TokensStatus + cljs.core/IPrintWithWriter + (-pr-writer [this writer opts] + (-write writer "#penpot/tokens-status ") + (-pr-writer (datafy this) writer opts)) + + cljs.core/IEncodeJS + (-clj->js [this] + (clj->js (datafy this))))) + +;; === Transit serialization === + +(t/add-handlers! + {:id "penpot/tokens-status" + :class TokensStatus + :wfn datafy + :rfn #(make-tokens-status %)}) + +;; === Fressian serialization === + +#?(:clj + (fres/add-handlers! + {:name "penpot/tokens-status/v1" + :class TokensStatus + :wfn (fn [n w o] + (fres/write-tag! w n 1) + (fres/write-object! w (datafy o))) + :rfn (fn [r] + (let [obj (fres/read-object! r)] + (make-tokens-status obj)))})) diff --git a/common/src/app/common/types/typographies_list.cljc b/common/src/app/common/types/typographies_list.cljc index 635c1431d3..fb0daec536 100644 --- a/common/src/app/common/types/typographies_list.cljc +++ b/common/src/app/common/types/typographies_list.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.typographies-list (:require diff --git a/common/src/app/common/types/typography.cljc b/common/src/app/common/types/typography.cljc index 241f5f3079..1c3f2fab93 100644 --- a/common/src/app/common/types/typography.cljc +++ b/common/src/app/common/types/typography.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.typography (:require diff --git a/common/src/app/common/types/variant.cljc b/common/src/app/common/types/variant.cljc index db65e42806..8a37939756 100644 --- a/common/src/app/common/types/variant.cljc +++ b/common/src/app/common/types/variant.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.variant (:require diff --git a/common/src/app/common/uri.cljc b/common/src/app/common/uri.cljc index b82b5c0e74..4284b47290 100644 --- a/common/src/app/common/uri.cljc +++ b/common/src/app/common/uri.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.uri (:refer-clojure :exclude [uri?]) @@ -67,6 +67,36 @@ path (str path "/"))))) +(defn- update-query-params + "Apply `f` to the query-params map of `url`, returning the updated URL string. + Handles both plain query strings and fragment-based (hash) URLs." + [url f] + (let [transform (fn [parsed] + (update parsed :query + (fn [q] + (-> (query-string->map (or q "")) + f + map->query-string)))) + parsed (uri url) + fragment (:fragment parsed)] + (if (str/blank? fragment) + (str (transform parsed)) + (-> parsed + (assoc :fragment (str (transform (parse fragment)))) + str)))) + +(defn append-query-param + "Return a new URL string with the given query parameter added or replaced. + Handles both plain query strings and fragment-based (hash) URLs." + [url key value] + (update-query-params url #(assoc % key value))) + +(defn remove-query-param + "Return a new URL string with the given query parameter removed. + Handles both plain query strings and fragment-based (hash) URLs." + [url key] + (update-query-params url #(dissoc % key))) + #?(:clj (defmethod print-method lambdaisland.uri.URI [^URI this ^java.io.Writer writer] (.write writer "#") diff --git a/common/src/app/common/uuid.cljc b/common/src/app/common/uuid.cljc index d094d4c06d..e890a595c1 100644 --- a/common/src/app/common/uuid.cljc +++ b/common/src/app/common/uuid.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL #_:clj-kondo/ignore (ns app.common.uuid diff --git a/common/src/app/common/uuid_impl.js b/common/src/app/common/uuid_impl.js index 9a868cc9a5..07a20269cb 100644 --- a/common/src/app/common/uuid_impl.js +++ b/common/src/app/common/uuid_impl.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ "use strict"; diff --git a/common/src/app/common/version.cljc b/common/src/app/common/version.cljc index fb4098557b..1b6a646f10 100644 --- a/common/src/app/common/version.cljc +++ b/common/src/app/common/version.cljc @@ -2,11 +2,12 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.version "A version parsing helper." (:require + [app.common.data :as d] [cuerdas.core :as str])) (def version-re #"^(([A-Za-z]+)\-?)?((\d+)\.(\d+)\.(\d+))(\-?((RC|DEV)(\d+)?))?(\-?(\d+))?(\-?g(\w+))?$") @@ -49,3 +50,24 @@ :else nil)) +(defn- version-components + [version] + (let [{:keys [major minor patch]} (or (parse version) {})] + [(d/parse-integer major 0) + (d/parse-integer minor 0) + (d/parse-integer patch 0)])) + +(defn compare-versions + "Compare two X.Y.Z base versions. Returns negative if a < b, zero if + equal, positive if a > b." + [version-a version-b] + (let [[major-a minor-a patch-a] (version-components version-a) + [major-b minor-b patch-b] (version-components version-b)] + (or (when (not= major-a major-b) (- major-a major-b)) + (when (not= minor-a minor-b) (- minor-a minor-b)) + (- patch-a patch-b)))) + +(defn newer? + [version-a version-b] + (pos? (compare-versions version-a version-b))) + diff --git a/common/src/app/common/weak.cljc b/common/src/app/common/weak.cljc index 7733f58ce6..6306d422d9 100644 --- a/common/src/app/common/weak.cljc +++ b/common/src/app/common/weak.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.weak "A collection of helpers for work with weak references and weak diff --git a/common/src/app/common/weak/impl_loadable_weak_value_map.clj b/common/src/app/common/weak/impl_loadable_weak_value_map.clj index a7801d8cec..6e582f26f1 100644 --- a/common/src/app/common/weak/impl_loadable_weak_value_map.clj +++ b/common/src/app/common/weak/impl_loadable_weak_value_map.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.weak.impl-loadable-weak-value-map (:import diff --git a/common/src/app/common/weak/impl_weak_map.js b/common/src/app/common/weak/impl_weak_map.js index 1a4d4fc31e..3c900634bd 100644 --- a/common/src/app/common/weak/impl_weak_map.js +++ b/common/src/app/common/weak/impl_weak_map.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ "use strict"; diff --git a/common/src/app/common/weak/impl_weak_value_map.js b/common/src/app/common/weak/impl_weak_value_map.js index 11eaba8b0d..8f8dc27586 100644 --- a/common/src/app/common/weak/impl_weak_value_map.js +++ b/common/src/app/common/weak/impl_weak_value_map.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ "use strict"; diff --git a/common/test/common_tests/attrs_test.cljc b/common/test/common_tests/attrs_test.cljc index bab8b9fbaf..340a89e0da 100644 --- a/common/test/common_tests/attrs_test.cljc +++ b/common/test/common_tests/attrs_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.attrs-test (:require diff --git a/common/test/common_tests/buffer_test.cljc b/common/test/common_tests/buffer_test.cljc index 612a4248cd..43b35e05d4 100644 --- a/common/test/common_tests/buffer_test.cljc +++ b/common/test/common_tests/buffer_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.buffer-test (:require diff --git a/common/test/common_tests/colors_test.cljc b/common/test/common_tests/colors_test.cljc index b79f9c6176..ee13f2e3b0 100644 --- a/common/test/common_tests/colors_test.cljc +++ b/common/test/common_tests/colors_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.colors-test (:require diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 39f3370de8..cd3e1c5eae 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.data-test (:require @@ -36,6 +36,43 @@ (t/is (= "" (d/get-initials nil))) (t/is (= "" (d/get-initials "!!! ???")))) +(t/deftest normalize-string-test + ;; nil input returns empty string + (t/is (= "" (d/normalize-string nil))) + ;; empty string returns empty string + (t/is (= "" (d/normalize-string ""))) + ;; leading whitespace is trimmed + (t/is (= "hello" (d/normalize-string " hello"))) + ;; trailing whitespace is trimmed + (t/is (= "hello" (d/normalize-string "hello "))) + ;; both leading and trailing whitespace are trimmed + (t/is (= "hello" (d/normalize-string " hello "))) + ;; internal whitespace is preserved + (t/is (= "hello world" (d/normalize-string " hello world "))) + ;; non-string input is returned unchanged + (t/is (= 42 (d/normalize-string 42))) + (t/is (= :keyword (d/normalize-string :keyword))) + (t/is (= true (d/normalize-string true)))) + +(t/deftest escape-markdown-test + (t/is (= "hello" (d/escape-markdown "hello"))) + (t/is (= "" (d/escape-markdown nil))) + (t/is (= "" (d/escape-markdown ""))) + (t/is (= "\\*bold\\*" (d/escape-markdown "*bold*"))) + (t/is (= "\\_italic\\_" (d/escape-markdown "_italic_"))) + (t/is (= "\\~strikethrough\\~" (d/escape-markdown "~strikethrough~"))) + (t/is (= "\\`code\\`" (d/escape-markdown "`code`"))) + (t/is (= "\\[link\\]\\(http://evil\\.com\\)" (d/escape-markdown "[link](http://evil.com)"))) + (t/is (= "\\> quote" (d/escape-markdown "> quote"))) + (t/is (= "\\# heading" (d/escape-markdown "# heading"))) + (t/is (= "\\@channel" (d/escape-markdown "@channel"))) + (t/is (= "\\!bang" (d/escape-markdown "!bang"))) + (t/is (= "normal\\-text" (d/escape-markdown "normal-text"))) + (t/is (= "a\\+b\\=c" (d/escape-markdown "a+b=c"))) + (t/is (= "pipe\\|separated" (d/escape-markdown "pipe|separated"))) + (t/is (= "curly\\{\\}braces" (d/escape-markdown "curly{}braces"))) + (t/is (= "backslash\\\\slash" (d/escape-markdown "backslash\\slash")))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Ordered Data Structures ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/common/test/common_tests/files/comp_processors_test.cljc b/common/test/common_tests/files/comp_processors_test.cljc index 412986ede4..6eb30ad82b 100644 --- a/common/test/common_tests/files/comp_processors_test.cljc +++ b/common/test/common_tests/files/comp_processors_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files.comp-processors-test (:require diff --git a/common/test/common_tests/files/helpers_test.cljc b/common/test/common_tests/files/helpers_test.cljc index 4205d562c7..56ef876499 100644 --- a/common/test/common_tests/files/helpers_test.cljc +++ b/common/test/common_tests/files/helpers_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files.helpers-test (:require diff --git a/common/test/common_tests/files/repair_test.cljc b/common/test/common_tests/files/repair_test.cljc new file mode 100644 index 0000000000..ad9532e497 --- /dev/null +++ b/common/test/common_tests/files/repair_test.cljc @@ -0,0 +1,230 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns common-tests.files.repair-test + "Tests for the validate / repair functions in app.common.files.validate + and app.common.files.repair. + + The tests generate cases of broken files and check that the validation functions + generate accurate errors, and that the repair functions return the file to + a stable state." + (:require + [app.common.files.repair :as cfr] + [app.common.files.validate :as cfv] + [app.common.test-helpers.components :as thc] + [app.common.test-helpers.files :as thf] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [app.common.test-helpers.variants :as thv] + [app.common.uuid :as uuid] + [clojure.test :as t])) + +(t/use-fixtures :each thi/test-fixture) + +(t/deftest repair-main-instance-not-a-variant + (t/testing "detect and repair a variant component whose root shape is not a variant" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + (ths/update-shape :root1 :variant-id nil)) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + root1' (ths/get-shape file' :root1 :page-label :page1)] + + (t/is (= 2 (count errors))) ;; There are two different checks that detect the same problem + (t/is (= :main-instance-not-a-variant (:code (first errors)))) + + (t/is (nil? errors')) + (t/is (= (thi/id :variant1) (:variant-id root1')))))) + +(t/deftest repair-invalid-variant-id-variant-component-bad-id + (t/testing "detect and repair a variant component whose variant id does not match the container's id" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + (ths/update-shape :root1 :variant-id (uuid/next))) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + root1' (ths/get-shape file' :root1 :page-label :page1)] + + (t/is (= 2 (count errors))) ;; There are two different validation that actually check the same problem + (t/is (= :main-instance-invalid-variant-id (:code (first errors)))) + (t/is (= :variant-component-bad-id (:code (second errors)))) + + (t/is (nil? errors')) + (t/is (= (thi/id :variant1) (:variant-id root1')))))) + +(t/deftest repair-invalid-variant-properties + (t/testing "detect and repair a second variant component whose properties do not match the first variant component's properties" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + ;; Component1 has ["Property 1", "Property 2"], component2 gets ["Property 1", "Property 3"] + ;; This breaks validation: prop-names mismatch (missing "Property 2", extra "Property 3") + (thc/update-component :component1 {:variant-properties [{:name "Property 1" :value "Value1"} + {:name "Property 2" :value "ValueA"}]}) + (thc/update-component :component2 {:variant-properties [{:name "Property 1" :value "Value2"} + {:name "Property 3" :value "ValueB"}]}) + (ths/update-shape :root1 :variant-name "Value1, ValueA") + (ths/update-shape :root2 :variant-name "Value2, ValueB")) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + comp1' (thc/get-component file' :component1) + comp2' (thc/get-component file' :component2) + root1' (ths/get-shape file' :root1) + root2' (ths/get-shape file' :root2)] + + (t/is (= 1 (count errors))) + (t/is (= :invalid-variant-properties (:code (first errors)))) + + (t/is (nil? errors')) + + ;; After repair, component1's properties are rebuilt to match component2's property names + ;; (the first child in the variant container is root2, so prop-names come from component2) + ;; "Property 1" keeps its value, "Property 3" is added with empty value, "Property 2" is removed + (t/is (= [{:name "Property 1" :value "Value1"} + {:name "Property 3" :value ""}] + (:variant-properties comp1'))) + + (t/is (= "Value1" (:variant-name root1'))) + + ;; Component2 is unchanged (it was the reference for the property names) + (t/is (= [{:name "Property 1" :value "Value2"} + {:name "Property 3" :value "ValueB"}] + (:variant-properties comp2'))) + + (t/is (= "Value2, ValueB" (:variant-name root2')))))) + +(t/deftest repair-variant-not-main + (t/testing "detect and repair a non-main-instance shape inside a variant container" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + ;; Add a third child to the variant container with :variant-id but NOT a main-instance + (ths/add-sample-shape :bad-shape + :type :frame + :parent-label :variant1 + :variant-id (thi/id :variant1) + :variant-name "") + ;; Add a child to the bad shape (to verify the repair deletes it too) + (ths/add-sample-shape :bad-child + :type :rect + :parent-label :bad-shape)) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + bad-shape' (ths/get-shape file' :bad-shape) + bad-child' (ths/get-shape file' :bad-child)] + + (t/is (= 4 (count errors))) ;; The bad container also triggers other errors + (t/is (= :invalid-variant-properties (:code (nth errors 0)))) + (t/is (= :variant-not-main (:code (nth errors 1)))) + (t/is (= :variant-component-bad-name (:code (nth errors 2)))) + (t/is (= :variant-component-bad-id (:code (nth errors 3)))) + (t/is (nil? errors')) + + (t/is (nil? bad-shape')) + (t/is (nil? bad-child'))))) + +(t/deftest repair-parent-not-variant + (t/testing "detect and repair a variant shape whose parent is not a variant-container" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + ;; Break the variant container + (ths/update-shape :variant1 :is-variant-container false)) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + container' (ths/get-shape file' :variant1)] + + (t/is (= 2 (count errors))) ;; The error is detected twice, once for each child of the variant container + (t/is (= :parent-not-variant (:code (first errors)))) + (t/is (= :parent-not-variant (:code (second errors)))) + (t/is (nil? errors')) + + (t/is (true? (:is-variant-container container')))))) + +(t/deftest repair-variant-main-bad-name + (t/testing "detect and repair a main instance whose name doesn't match the variant container's name" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + ;; Change root1's name so it doesn't match the container + (ths/update-shape :root1 :name "WrongName")) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + root1' (ths/get-shape file' :root1)] + + (t/is (= 1 (count errors))) + (t/is (= :variant-main-bad-name (:code (first errors)))) + (t/is (nil? errors')) + (t/is (= "Board" (:name root1')))))) + +(t/deftest repair-variant-main-bad-variant-name + (t/testing "detect and repair a variant shape whose :variant-name doesn't match the component's properties" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + (thc/update-component :component1 {:variant-properties [{:name "Property 1" :value "Value1"} + {:name "Property 2" :value "ValueA"}]}) + (thc/update-component :component2 {:variant-properties [{:name "Property 1" :value "Value2"} + {:name "Property 2" :value "ValueB"}]}) + ;; Change root1's :variant-name to something wrong + (ths/update-shape :root1 :variant-name "WrongVariantName") + (ths/update-shape :root2 :variant-name "Value2, ValueB")) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + root1' (ths/get-shape file' :root1)] + + (t/is (= 1 (count errors))) + (t/is (= :variant-main-bad-variant-name (:code (first errors)))) + (t/is (nil? errors')) + (t/is (= "Value1, ValueA" (:variant-name root1')))))) + +(t/deftest repair-variant-component-bad-name + (t/testing "detect and repair a variant component whose path/name doesn't match the container name" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + ;; Update names to have path structure + (ths/update-shape :variant1 :name "Group / Subgroup / Component") + (ths/update-shape :root1 :name "Group / Subgroup / Component") + (ths/update-shape :root2 :name "Group / Subgroup / Component") + ;; Update component paths and names + (thc/update-component :component1 {:path "Group / Subgroup" :name "Component"}) + (thc/update-component :component2 {:path "Group / Subgroup" :name "Component"}) + ;; Break component1's name + (thc/update-component :component1 {:name "WrongName"})) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + comp1' (thc/get-component file' :component1)] + + (t/is (= 1 (count errors))) + (t/is (= :variant-component-bad-name (:code (first errors)))) + (t/is (nil? errors')) + (t/is (= "Group / Subgroup" (:path comp1'))) + (t/is (= "Component" (:name comp1')))))) diff --git a/common/test/common_tests/files/shapes_builder_test.cljc b/common/test/common_tests/files/shapes_builder_test.cljc index f57fe7ac07..614ff7cf77 100644 --- a/common/test/common_tests/files/shapes_builder_test.cljc +++ b/common/test/common_tests/files/shapes_builder_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files.shapes-builder-test (:require diff --git a/common/test/common_tests/files/tokens_test.cljc b/common/test/common_tests/files/tokens_test.cljc index 63f083ce5e..e196ba4ac1 100644 --- a/common/test/common_tests/files/tokens_test.cljc +++ b/common/test/common_tests/files/tokens_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files.tokens-test (:require diff --git a/common/test/common_tests/files/validate_test.cljc b/common/test/common_tests/files/validate_test.cljc index 271cfa611e..48fd3e3f07 100644 --- a/common/test/common_tests/files/validate_test.cljc +++ b/common/test/common_tests/files/validate_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files.validate-test "Exhaustive tests for the change-scoped partial validation functions in diff --git a/common/test/common_tests/files_builder_test.cljc b/common/test/common_tests/files_builder_test.cljc index 993f5cd827..707d56d3f0 100644 --- a/common/test/common_tests/files_builder_test.cljc +++ b/common/test/common_tests/files_builder_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files-builder-test (:require diff --git a/common/test/common_tests/files_changes_test.cljc b/common/test/common_tests/files_changes_test.cljc index 7671f7a787..4d91bf4f7f 100644 --- a/common/test/common_tests/files_changes_test.cljc +++ b/common/test/common_tests/files_changes_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files-changes-test (:require diff --git a/common/test/common_tests/files_migrations_0025_test.cljc b/common/test/common_tests/files_migrations_0025_test.cljc index 4d57398953..36480bd3cc 100644 --- a/common/test/common_tests/files_migrations_0025_test.cljc +++ b/common/test/common_tests/files_migrations_0025_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files-migrations-0025-test (:require diff --git a/common/test/common_tests/files_migrations_0026_test.cljc b/common/test/common_tests/files_migrations_0026_test.cljc new file mode 100644 index 0000000000..92dde088e8 --- /dev/null +++ b/common/test/common_tests/files_migrations_0026_test.cljc @@ -0,0 +1,110 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns common-tests.files-migrations-0026-test + (:require + [app.common.files.migrations :as cfm] + [app.common.uuid :as uuid] + [clojure.test :as t])) + +;; 0026-fix-svg-raw-shapes-uuids +;; Before the svg-raw schema declared :shapes as a vector of uuid, the +;; JSON decoder had no type information for those child ids and left +;; them as plain strings on any round trip, so they got persisted as +;; strings. Once the schema was tightened, such files fail schema +;; validation; this migration parses the strings back into uuids. + +(defn- make-svg-raw-shape + "Build a minimal svg-raw shape with the supplied :shapes vector. + When `shapes` is nil the :shapes key is omitted, like a leaf svg-raw + shape." + [shape-id shapes] + (cond-> {:id shape-id + :type :svg-raw} + (some? shapes) + (assoc :shapes shapes))) + +(defn- make-other-shape + "Build a minimal non-svg-raw shape that must stay untouched." + [shape-id shapes] + {:id shape-id + :type :group + :shapes shapes}) + +(t/deftest migration-0026-converts-svg-raw-shapes-strings-to-uuids-in-pages + (let [shape-id (uuid/next) + child-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-svg-raw-shape + shape-id + [(str child-id) + "1c2986ce-4a0f-8001-8007-1fb8f3b5ab31"])}}}} + data' (cfm/migrate-data data "0026-fix-svg-raw-shapes-uuids") + shape (get-in data' [:pages-index page-id :objects shape-id])] + + (t/is (= 2 (count (:shapes shape))) "child ids preserved") + (t/is (= child-id (first (:shapes shape))) "existing uuid string parsed to uuid") + (t/is (= #uuid "1c2986ce-4a0f-8001-8007-1fb8f3b5ab31" (second (:shapes shape))) + "foreign uuid string parsed to uuid") + (t/is (every? uuid? (:shapes shape)) "all child ids are uuids"))) + +(t/deftest migration-0026-converts-svg-raw-shapes-strings-to-uuids-in-components + (let [shape-id (uuid/next) + child-id (uuid/next) + component-id (uuid/next) + data {:components + {component-id + {:objects + {shape-id (make-svg-raw-shape + shape-id + [(str child-id) + "1c2986ce-4a0f-8001-8007-1fb92196e65f"])}}}} + data' (cfm/migrate-data data "0026-fix-svg-raw-shapes-uuids") + shape (get-in data' [:components component-id :objects shape-id])] + + (t/is (= 2 (count (:shapes shape))) "child ids preserved") + (t/is (= child-id (first (:shapes shape))) "existing uuid string parsed to uuid") + (t/is (= #uuid "1c2986ce-4a0f-8001-8007-1fb92196e65f" (second (:shapes shape))) + "foreign uuid string parsed to uuid") + (t/is (every? uuid? (:shapes shape)) "all child ids are uuids"))) + +(t/deftest migration-0026-leaves-uuids-and-other-shapes-untouched + (let [svg-raw-id (uuid/next) + child-id (uuid/next) + group-id (uuid/next) + leaf-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {svg-raw-id (make-svg-raw-shape svg-raw-id [child-id]) + group-id (make-other-shape group-id [(str child-id)]) + leaf-id (make-svg-raw-shape leaf-id nil)}}}} + data' (cfm/migrate-data data "0026-fix-svg-raw-shapes-uuids") + objects (get-in data' [:pages-index page-id :objects])] + + (t/is (= [child-id] (:shapes (get objects svg-raw-id))) + "already-uuid svg-raw children untouched") + (t/is (= [(str child-id)] (:shapes (get objects group-id))) + "non-svg-raw shapes untouched") + (t/is (nil? (:shapes (get objects leaf-id))) + "svg-raw leaf without :shapes untouched"))) + +(t/deftest migration-0026-is-idempotent + (let [shape-id (uuid/next) + child-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-svg-raw-shape shape-id [(str child-id)])}}}} + data' (cfm/migrate-data data "0026-fix-svg-raw-shapes-uuids") + data'' (cfm/migrate-data data' "0026-fix-svg-raw-shapes-uuids")] + + (t/is (= data' data'') "second run is a no-op"))) \ No newline at end of file diff --git a/common/test/common_tests/files_migrations_test.cljc b/common/test/common_tests/files_migrations_test.cljc index 36ff3a09e2..7a8f757c45 100644 --- a/common/test/common_tests/files_migrations_test.cljc +++ b/common/test/common_tests/files_migrations_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files-migrations-test (:require diff --git a/common/test/common_tests/fressian_test.clj b/common/test/common_tests/fressian_test.clj index 9af54464a5..d405a01e7c 100644 --- a/common/test/common_tests/fressian_test.clj +++ b/common/test/common_tests/fressian_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.fressian-test "Exhaustive unit tests for app.common.fressian encode/decode functions. @@ -21,7 +21,8 @@ (:import java.time.Instant java.time.OffsetDateTime - java.time.ZoneOffset)) + java.time.ZoneOffset + java.util.UUID)) ;; --------------------------------------------------------------------------- ;; Helpers @@ -524,3 +525,18 @@ (t/is (d/ordered-map? rt)) (t/is (= om rt)) (t/is (= (keys om) (keys rt))))) + +(t/deftest decode-rejects-excessive-recursion-depth + ;; N2-01: deeply nested structures must be rejected before stack overflow + (let [depth (+ fres/max-read-depth 50) + data (reduce (fn [acc _i] [acc]) + :leaf + (range depth)) + encoded (fres/encode data)] + (try + (fres/decode encoded) + (t/is false "expected exception for excessive recursion depth") + (catch clojure.lang.ExceptionInfo e + (let [d (ex-data e)] + (t/is (= :validation (:type d))) + (t/is (= :max-read-depth-reached (:code d)))))))) diff --git a/common/test/common_tests/geom_align_test.cljc b/common/test/common_tests/geom_align_test.cljc index dc72fccf5d..5d71bf6030 100644 --- a/common/test/common_tests/geom_align_test.cljc +++ b/common/test/common_tests/geom_align_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-align-test (:require diff --git a/common/test/common_tests/geom_bounds_layout_nil_test.cljc b/common/test/common_tests/geom_bounds_layout_nil_test.cljc index db070ce4f4..dda9fa957f 100644 --- a/common/test/common_tests/geom_bounds_layout_nil_test.cljc +++ b/common/test/common_tests/geom_bounds_layout_nil_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-bounds-layout-nil-test (:require diff --git a/common/test/common_tests/geom_bounds_map_test.cljc b/common/test/common_tests/geom_bounds_map_test.cljc index 23b239eb43..4321c6a13e 100644 --- a/common/test/common_tests/geom_bounds_map_test.cljc +++ b/common/test/common_tests/geom_bounds_map_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-bounds-map-test (:require diff --git a/common/test/common_tests/geom_flex_layout_test.cljc b/common/test/common_tests/geom_flex_layout_test.cljc index bc63b03c8c..c016daeea7 100644 --- a/common/test/common_tests/geom_flex_layout_test.cljc +++ b/common/test/common_tests/geom_flex_layout_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-flex-layout-test (:require diff --git a/common/test/common_tests/geom_grid_layout_test.cljc b/common/test/common_tests/geom_grid_layout_test.cljc index 369406ef38..1769ab634a 100644 --- a/common/test/common_tests/geom_grid_layout_test.cljc +++ b/common/test/common_tests/geom_grid_layout_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-grid-layout-test (:require diff --git a/common/test/common_tests/geom_grid_test.cljc b/common/test/common_tests/geom_grid_test.cljc index 9a3645eb8b..6d3d823882 100644 --- a/common/test/common_tests/geom_grid_test.cljc +++ b/common/test/common_tests/geom_grid_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-grid-test (:require diff --git a/common/test/common_tests/geom_line_test.cljc b/common/test/common_tests/geom_line_test.cljc index d6e6df5360..b1f0f8e659 100644 --- a/common/test/common_tests/geom_line_test.cljc +++ b/common/test/common_tests/geom_line_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-line-test (:require diff --git a/common/test/common_tests/geom_modif_tree_test.cljc b/common/test/common_tests/geom_modif_tree_test.cljc index 80088703ce..1ce29057e1 100644 --- a/common/test/common_tests/geom_modif_tree_test.cljc +++ b/common/test/common_tests/geom_modif_tree_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-modif-tree-test (:require diff --git a/common/test/common_tests/geom_modifiers_test.cljc b/common/test/common_tests/geom_modifiers_test.cljc index 8784ff25f7..8494975e53 100644 --- a/common/test/common_tests/geom_modifiers_test.cljc +++ b/common/test/common_tests/geom_modifiers_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-modifiers-test (:require diff --git a/common/test/common_tests/geom_point_test.cljc b/common/test/common_tests/geom_point_test.cljc index 1691c06757..d2742741bd 100644 --- a/common/test/common_tests/geom_point_test.cljc +++ b/common/test/common_tests/geom_point_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-point-test (:require diff --git a/common/test/common_tests/geom_proportions_test.cljc b/common/test/common_tests/geom_proportions_test.cljc index 3cb94c99fd..8aa05bd693 100644 --- a/common/test/common_tests/geom_proportions_test.cljc +++ b/common/test/common_tests/geom_proportions_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-proportions-test (:require diff --git a/common/test/common_tests/geom_rect_test.cljc b/common/test/common_tests/geom_rect_test.cljc index 8abfb76854..2f6e8e80f7 100644 --- a/common/test/common_tests/geom_rect_test.cljc +++ b/common/test/common_tests/geom_rect_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-rect-test (:require diff --git a/common/test/common_tests/geom_shapes_common_test.cljc b/common/test/common_tests/geom_shapes_common_test.cljc index 0a9e47f21d..1d7d47112b 100644 --- a/common/test/common_tests/geom_shapes_common_test.cljc +++ b/common/test/common_tests/geom_shapes_common_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-shapes-common-test (:require diff --git a/common/test/common_tests/geom_shapes_constraints_test.cljc b/common/test/common_tests/geom_shapes_constraints_test.cljc index 175cc6f77b..4f6cd3ce4c 100644 --- a/common/test/common_tests/geom_shapes_constraints_test.cljc +++ b/common/test/common_tests/geom_shapes_constraints_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-shapes-constraints-test (:require diff --git a/common/test/common_tests/geom_shapes_corners_test.cljc b/common/test/common_tests/geom_shapes_corners_test.cljc index 80efad7679..46692a28dd 100644 --- a/common/test/common_tests/geom_shapes_corners_test.cljc +++ b/common/test/common_tests/geom_shapes_corners_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-shapes-corners-test (:require diff --git a/common/test/common_tests/geom_shapes_effects_test.cljc b/common/test/common_tests/geom_shapes_effects_test.cljc index eeccde458a..af669686c9 100644 --- a/common/test/common_tests/geom_shapes_effects_test.cljc +++ b/common/test/common_tests/geom_shapes_effects_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-shapes-effects-test (:require diff --git a/common/test/common_tests/geom_shapes_intersect_test.cljc b/common/test/common_tests/geom_shapes_intersect_test.cljc index a670d938c4..ee1c082534 100644 --- a/common/test/common_tests/geom_shapes_intersect_test.cljc +++ b/common/test/common_tests/geom_shapes_intersect_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-shapes-intersect-test (:require diff --git a/common/test/common_tests/geom_shapes_strokes_test.cljc b/common/test/common_tests/geom_shapes_strokes_test.cljc index 1993add116..1ba89f00cb 100644 --- a/common/test/common_tests/geom_shapes_strokes_test.cljc +++ b/common/test/common_tests/geom_shapes_strokes_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-shapes-strokes-test (:require diff --git a/common/test/common_tests/geom_shapes_test.cljc b/common/test/common_tests/geom_shapes_test.cljc index 87805559e6..e60ded0fe7 100644 --- a/common/test/common_tests/geom_shapes_test.cljc +++ b/common/test/common_tests/geom_shapes_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-shapes-test (:require diff --git a/common/test/common_tests/geom_shapes_text_test.cljc b/common/test/common_tests/geom_shapes_text_test.cljc index 17185a67a1..7c1eef6bf3 100644 --- a/common/test/common_tests/geom_shapes_text_test.cljc +++ b/common/test/common_tests/geom_shapes_text_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-shapes-text-test (:require diff --git a/common/test/common_tests/geom_shapes_tree_seq_test.cljc b/common/test/common_tests/geom_shapes_tree_seq_test.cljc index b2df798e41..58c5c535f8 100644 --- a/common/test/common_tests/geom_shapes_tree_seq_test.cljc +++ b/common/test/common_tests/geom_shapes_tree_seq_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-shapes-tree-seq-test (:require diff --git a/common/test/common_tests/geom_snap_test.cljc b/common/test/common_tests/geom_snap_test.cljc index 14776e1293..a7af7443b1 100644 --- a/common/test/common_tests/geom_snap_test.cljc +++ b/common/test/common_tests/geom_snap_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-snap-test (:require diff --git a/common/test/common_tests/geom_test.cljc b/common/test/common_tests/geom_test.cljc index ff14261eb7..98e80f7302 100644 --- a/common/test/common_tests/geom_test.cljc +++ b/common/test/common_tests/geom_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-test (:require diff --git a/common/test/common_tests/helpers_test.cljc b/common/test/common_tests/helpers_test.cljc index 96c5b37fa0..437874b019 100644 --- a/common/test/common_tests/helpers_test.cljc +++ b/common/test/common_tests/helpers_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.helpers-test (:require diff --git a/common/test/common_tests/logic/chained_propagation_test.cljc b/common/test/common_tests/logic/chained_propagation_test.cljc index be9a05487e..32a98e68dc 100644 --- a/common/test/common_tests/logic/chained_propagation_test.cljc +++ b/common/test/common_tests/logic/chained_propagation_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.chained-propagation-test (:require diff --git a/common/test/common_tests/logic/comp_creation_test.cljc b/common/test/common_tests/logic/comp_creation_test.cljc index 4021d3138b..d28e5f3b31 100644 --- a/common/test/common_tests/logic/comp_creation_test.cljc +++ b/common/test/common_tests/logic/comp_creation_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.comp-creation-test (:require diff --git a/common/test/common_tests/logic/comp_detach_with_nested_test.cljc b/common/test/common_tests/logic/comp_detach_with_nested_test.cljc index c4020a48f2..3e06167e35 100644 --- a/common/test/common_tests/logic/comp_detach_with_nested_test.cljc +++ b/common/test/common_tests/logic/comp_detach_with_nested_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.comp-detach-with-nested-test (:require diff --git a/common/test/common_tests/logic/comp_main_edit_breaks_copy_slots_test.cljc b/common/test/common_tests/logic/comp_main_edit_breaks_copy_slots_test.cljc index 03ceb6b8ee..d02a06b9e0 100644 --- a/common/test/common_tests/logic/comp_main_edit_breaks_copy_slots_test.cljc +++ b/common/test/common_tests/logic/comp_main_edit_breaks_copy_slots_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.comp-main-edit-breaks-copy-slots-test (:require diff --git a/common/test/common_tests/logic/comp_remove_swap_slots_test.cljc b/common/test/common_tests/logic/comp_remove_swap_slots_test.cljc index 08c852bfc3..da766cd71d 100644 --- a/common/test/common_tests/logic/comp_remove_swap_slots_test.cljc +++ b/common/test/common_tests/logic/comp_remove_swap_slots_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.comp-remove-swap-slots-test (:require diff --git a/common/test/common_tests/logic/comp_reset_test.cljc b/common/test/common_tests/logic/comp_reset_test.cljc index 23b1136657..d9491271a3 100644 --- a/common/test/common_tests/logic/comp_reset_test.cljc +++ b/common/test/common_tests/logic/comp_reset_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.comp-reset-test (:require diff --git a/common/test/common_tests/logic/comp_sync_test.cljc b/common/test/common_tests/logic/comp_sync_test.cljc index 8a7b652328..a929c7a616 100644 --- a/common/test/common_tests/logic/comp_sync_test.cljc +++ b/common/test/common_tests/logic/comp_sync_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.comp-sync-test (:require diff --git a/common/test/common_tests/logic/comp_touched_test.cljc b/common/test/common_tests/logic/comp_touched_test.cljc index 5d91fcbc1a..b31874d128 100644 --- a/common/test/common_tests/logic/comp_touched_test.cljc +++ b/common/test/common_tests/logic/comp_touched_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.comp-touched-test (:require diff --git a/common/test/common_tests/logic/copying_and_duplicating_test.cljc b/common/test/common_tests/logic/copying_and_duplicating_test.cljc index 784c3d81ff..2c8f1161ee 100644 --- a/common/test/common_tests/logic/copying_and_duplicating_test.cljc +++ b/common/test/common_tests/logic/copying_and_duplicating_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.copying-and-duplicating-test (:require diff --git a/common/test/common_tests/logic/duplicated_pages_test.cljc b/common/test/common_tests/logic/duplicated_pages_test.cljc index 70a0e9d206..34222703ab 100644 --- a/common/test/common_tests/logic/duplicated_pages_test.cljc +++ b/common/test/common_tests/logic/duplicated_pages_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.duplicated-pages-test (:require diff --git a/common/test/common_tests/logic/move_shapes_test.cljc b/common/test/common_tests/logic/move_shapes_test.cljc index 09ec4c09db..846f9c98f3 100644 --- a/common/test/common_tests/logic/move_shapes_test.cljc +++ b/common/test/common_tests/logic/move_shapes_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.move-shapes-test (:require diff --git a/common/test/common_tests/logic/multiple_nesting_levels_test.cljc b/common/test/common_tests/logic/multiple_nesting_levels_test.cljc index 01544ce3f2..f1afd06b9d 100644 --- a/common/test/common_tests/logic/multiple_nesting_levels_test.cljc +++ b/common/test/common_tests/logic/multiple_nesting_levels_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.multiple-nesting-levels-test (:require diff --git a/common/test/common_tests/logic/swap_and_reset_test.cljc b/common/test/common_tests/logic/swap_and_reset_test.cljc index c9cad989cc..6e1fa2e3da 100644 --- a/common/test/common_tests/logic/swap_and_reset_test.cljc +++ b/common/test/common_tests/logic/swap_and_reset_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.swap-and-reset-test (:require diff --git a/common/test/common_tests/logic/swap_as_override_test.cljc b/common/test/common_tests/logic/swap_as_override_test.cljc index 6a0a0b0491..4c41b030e9 100644 --- a/common/test/common_tests/logic/swap_as_override_test.cljc +++ b/common/test/common_tests/logic/swap_as_override_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.swap-as-override-test (:require diff --git a/common/test/common_tests/logic/swap_keeps_id_test.cljc b/common/test/common_tests/logic/swap_keeps_id_test.cljc index c3d001a0b6..d94981ff55 100644 --- a/common/test/common_tests/logic/swap_keeps_id_test.cljc +++ b/common/test/common_tests/logic/swap_keeps_id_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.swap-keeps-id-test (:require diff --git a/common/test/common_tests/logic/text_sync_test.cljc b/common/test/common_tests/logic/text_sync_test.cljc index 335994c05d..0863b3e75e 100644 --- a/common/test/common_tests/logic/text_sync_test.cljc +++ b/common/test/common_tests/logic/text_sync_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.text-sync-test (:require diff --git a/common/test/common_tests/logic/text_touched_test.cljc b/common/test/common_tests/logic/text_touched_test.cljc index f102880ba6..a82c635b5f 100644 --- a/common/test/common_tests/logic/text_touched_test.cljc +++ b/common/test/common_tests/logic/text_touched_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.text-touched-test (:require diff --git a/common/test/common_tests/logic/token_apply_test.cljc b/common/test/common_tests/logic/token_apply_test.cljc index 9715b7f235..b4a2c92c1f 100644 --- a/common/test/common_tests/logic/token_apply_test.cljc +++ b/common/test/common_tests/logic/token_apply_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.token-apply-test (:require diff --git a/common/test/common_tests/logic/token_test.cljc b/common/test/common_tests/logic/token_test.cljc index 6d54874ae4..16258b8c8f 100644 --- a/common/test/common_tests/logic/token_test.cljc +++ b/common/test/common_tests/logic/token_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.token-test (:require diff --git a/common/test/common_tests/logic/variants_switch_test.cljc b/common/test/common_tests/logic/variants_switch_test.cljc index ed9eeae783..06e8b5ac34 100644 --- a/common/test/common_tests/logic/variants_switch_test.cljc +++ b/common/test/common_tests/logic/variants_switch_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.variants-switch-test (:require @@ -10,6 +10,7 @@ [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] + [app.common.logic.libraries :as cll] [app.common.logic.shapes :as cls] [app.common.test-helpers.components :as thc] [app.common.test-helpers.compositions :as tho] @@ -3101,3 +3102,82 @@ (t/is (= 150 (:width rect02'))) (t/is (= (+ (:y copy02') 70) (:y rect02'))) (t/is (= (:y rect02') (get-in rect02' [:selrect :y]))))) + +;; ============================================================ +;; PRESERVE TEXT SUB-TOUCHED FLAGS ACROSS VARIANT SWITCH +;; ============================================================ + +(t/deftest test-switch-preserves-text-sub-touched-flags + ;; 1. Creates a component with text "hello world" + font-size "14", variant with font-size "20" + ;; 2. Overrides only text on the copy → verifies :text-content-text in touched + ;; 3. Switches to variant → verifies text override preserved, font-size updated, :text-content-text preserved + ;; 4. Updates main font-size to "30" and syncs → verifies font-size synced but text override preserved + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; c01 has text "hello world" font-size "14" + ;; c02 has text "hello world" font-size "20" (same text, different font-size) + (thv/add-variant-with-text + :v01 :c01 :m01 :c02 :m02 :t01 :t02 "hello world" "hello world") + (update-attr :t02 font-size-path-0 "20") + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-t01])) + + ;; Override only the TEXT on the copy (not font-size) + file (update-attr file :copy-t01 text-path-0 "custom text") + copy-t01 (ths/get-shape file :copy-t01)] + + ;; Verify the copy has the text override and correct touched flags + (t/is (= (get-in copy-t01 text-path-0) "custom text")) + (t/is (= (get-in copy-t01 font-size-path-0) "14")) + (t/is (contains? (:touched copy-t01) :content-group)) + (t/is (contains? (:touched copy-t01) :text-content-text)) + (t/is (not (contains? (:touched copy-t01) :text-content-attribute))) + (t/is (not (contains? (:touched copy-t01) :text-content-structure))) + + ;; ==== Action: Switch copy to c02 variant (same text, different font-size) + (let [file' (tho/swap-component-in-shape file :copy01 :c02 + {:new-shape-label :copy02 + :keep-touched? true}) + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + copy-t02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; After switch: text override preserved (same text between variants), + ;; font-size updated from variant, touched preserves text-content-text + (t/is (= (get-in copy-t02' text-path-0) "custom text")) + (t/is (= (get-in copy-t02' font-size-path-0) "20")) + (t/is (contains? (:touched copy-t02') :content-group)) + (t/is (contains? (:touched copy-t02') :text-content-text)) + (t/is (not (contains? (:touched copy-t02') :text-content-attribute))) + + ;; ==== Now test subsequent component sync + ;; Modify the main component's font-size to "30" (keeping text "hello world") + (let [main-text (ths/get-shape file' :t02) + changes1 (cls/generate-update-shapes (pcb/empty-changes nil (:id page')) + #{(:id main-text)} + (fn [shape] + (assoc-in shape font-size-path-0 "30")) + (:objects page') + {}) + updated-file (thf/apply-changes file' changes1) + + changes2 (cll/generate-sync-file-changes (pcb/empty-changes) + nil + :components + (:id updated-file) + (thi/id :c02) + (:id updated-file) + {(:id updated-file) updated-file} + (:id updated-file)) + + synced-file (thf/apply-changes updated-file changes2) + synced-copy (ths/get-shape synced-file :copy02) + synced-t (get-in (thf/current-page synced-file) + [:objects (-> synced-copy :shapes first)])] + + ;; The text override is preserved and font-size is synced + (t/is (= (get-in synced-t text-path-0) "custom text")) + (t/is (= (get-in synced-t font-size-path-0) "30")) + (t/is (contains? (:touched synced-t) :content-group)) + (t/is (contains? (:touched synced-t) :text-content-text)))))) diff --git a/common/test/common_tests/logic/variants_test.cljc b/common/test/common_tests/logic/variants_test.cljc index 43cb163c1e..c4cfc1b95a 100644 --- a/common/test/common_tests/logic/variants_test.cljc +++ b/common/test/common_tests/logic/variants_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.variants-test (:require diff --git a/common/test/common_tests/math_test.cljc b/common/test/common_tests/math_test.cljc index e644101895..524963df9b 100644 --- a/common/test/common_tests/math_test.cljc +++ b/common/test/common_tests/math_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.math-test (:require diff --git a/common/test/common_tests/media_test.cljc b/common/test/common_tests/media_test.cljc index c6916e3216..24302a02d1 100644 --- a/common/test/common_tests/media_test.cljc +++ b/common/test/common_tests/media_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.media-test (:require diff --git a/common/test/common_tests/path_names_test.cljc b/common/test/common_tests/path_names_test.cljc index bddb94fc13..9e0d3fb873 100644 --- a/common/test/common_tests/path_names_test.cljc +++ b/common/test/common_tests/path_names_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.path-names-test (:require diff --git a/common/test/common_tests/record_test.cljc b/common/test/common_tests/record_test.cljc index 6878978414..2a5e6206de 100644 --- a/common/test/common_tests/record_test.cljc +++ b/common/test/common_tests/record_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.record-test (:require diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index b24b045e1a..ab075151ee 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.runner (:require @@ -19,6 +19,7 @@ [common-tests.files-builder-test] [common-tests.files-changes-test] [common-tests.files-migrations-0025-test] + [common-tests.files-migrations-0026-test] [common-tests.files-migrations-test] [common-tests.files.shapes-builder-test] [common-tests.files.validate-test] @@ -85,6 +86,7 @@ [common-tests.types.shape-layout-test] [common-tests.types.token-test] [common-tests.types.tokens-lib-test] + [common-tests.types.tokens-status-test] [common-tests.undo-stack-test] [common-tests.uuid-test])) @@ -97,6 +99,7 @@ 'common-tests.files-changes-test 'common-tests.files-builder-test 'common-tests.files-migrations-0025-test + 'common-tests.files-migrations-0026-test 'common-tests.files-migrations-test 'common-tests.files.validate-test 'common-tests.geom-align-test @@ -162,6 +165,7 @@ 'common-tests.types.shape-layout-test 'common-tests.types.token-test 'common-tests.types.tokens-lib-test + 'common-tests.types.tokens-status-test 'common-tests.undo-stack-test 'common-tests.uuid-test]) diff --git a/common/test/common_tests/schema_test.cljc b/common/test/common_tests/schema_test.cljc index b14f1df0f5..041e9a2670 100644 --- a/common/test/common_tests/schema_test.cljc +++ b/common/test/common_tests/schema_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.schema-test (:require diff --git a/common/test/common_tests/spec_test.cljc b/common/test/common_tests/spec_test.cljc index 30eda62fc0..e40fa52157 100644 --- a/common/test/common_tests/spec_test.cljc +++ b/common/test/common_tests/spec_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.spec-test (:require diff --git a/common/test/common_tests/svg_path_test.cljc b/common/test/common_tests/svg_path_test.cljc index a59ed8c521..b82f1838a8 100644 --- a/common/test/common_tests/svg_path_test.cljc +++ b/common/test/common_tests/svg_path_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.svg-path-test (:require diff --git a/common/test/common_tests/svg_test.cljc b/common/test/common_tests/svg_test.cljc index 89fa30eae7..ed2c345037 100644 --- a/common/test/common_tests/svg_test.cljc +++ b/common/test/common_tests/svg_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.svg-test (:require diff --git a/common/test/common_tests/text_test.cljc b/common/test/common_tests/text_test.cljc index dcb07bca15..c3db06c5b9 100644 --- a/common/test/common_tests/text_test.cljc +++ b/common/test/common_tests/text_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.text-test (:require diff --git a/common/test/common_tests/time_test.cljc b/common/test/common_tests/time_test.cljc index 3015c4fd36..a80ed8f16b 100644 --- a/common/test/common_tests/time_test.cljc +++ b/common/test/common_tests/time_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.time-test (:require diff --git a/common/test/common_tests/token_test.cljc b/common/test/common_tests/token_test.cljc index 5065bfc396..0a998939dd 100644 --- a/common/test/common_tests/token_test.cljc +++ b/common/test/common_tests/token_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.token-test (:require diff --git a/common/test/common_tests/types/absorb_assets_test.cljc b/common/test/common_tests/types/absorb_assets_test.cljc index f115e1accc..8e8ee2ee36 100644 --- a/common/test/common_tests/types/absorb_assets_test.cljc +++ b/common/test/common_tests/types/absorb_assets_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.absorb-assets-test (:require diff --git a/common/test/common_tests/types/color_test.cljc b/common/test/common_tests/types/color_test.cljc index ad0155adf3..0679d7130f 100644 --- a/common/test/common_tests/types/color_test.cljc +++ b/common/test/common_tests/types/color_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.color-test (:require diff --git a/common/test/common_tests/types/components_test.cljc b/common/test/common_tests/types/components_test.cljc index 9a63464ce3..4d42df92f8 100644 --- a/common/test/common_tests/types/components_test.cljc +++ b/common/test/common_tests/types/components_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.components-test (:require diff --git a/common/test/common_tests/types/container_test.cljc b/common/test/common_tests/types/container_test.cljc index 6ab45c4f0a..e00b1fb9c6 100644 --- a/common/test/common_tests/types/container_test.cljc +++ b/common/test/common_tests/types/container_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.container-test (:require diff --git a/common/test/common_tests/types/fill_test.cljc b/common/test/common_tests/types/fill_test.cljc index 0a22c1e866..147da3f098 100644 --- a/common/test/common_tests/types/fill_test.cljc +++ b/common/test/common_tests/types/fill_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.fill-test (:require diff --git a/common/test/common_tests/types/font_test.cljc b/common/test/common_tests/types/font_test.cljc index c381c14893..b9281e7fd5 100644 --- a/common/test/common_tests/types/font_test.cljc +++ b/common/test/common_tests/types/font_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.font-test (:require diff --git a/common/test/common_tests/types/modifiers_test.cljc b/common/test/common_tests/types/modifiers_test.cljc index 405da89935..c427f6e93f 100644 --- a/common/test/common_tests/types/modifiers_test.cljc +++ b/common/test/common_tests/types/modifiers_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.modifiers-test (:require diff --git a/common/test/common_tests/types/objects_map_test.cljc b/common/test/common_tests/types/objects_map_test.cljc index c9999a6260..7eae046e3a 100644 --- a/common/test/common_tests/types/objects_map_test.cljc +++ b/common/test/common_tests/types/objects_map_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.objects-map-test (:require diff --git a/common/test/common_tests/types/organization_test.cljc b/common/test/common_tests/types/organization_test.cljc index e6a24aeb00..1d2c48be01 100644 --- a/common/test/common_tests/types/organization_test.cljc +++ b/common/test/common_tests/types/organization_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.organization-test (:require diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index 714270cb62..70285f21ad 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.path-data-test (:require @@ -17,6 +17,7 @@ [app.common.transit :as trans] [app.common.types.path :as path] [app.common.types.path.bool :as path.bool] + [app.common.types.path.fit :as path.fit] [app.common.types.path.helpers :as path.helpers] [app.common.types.path.impl :as path.impl] [app.common.types.path.segment :as path.segment] @@ -656,6 +657,21 @@ (t/testing "content that is already a closed triangle stays closed" (let [result (path.subpath/close-subpaths simple-closed-content)] (t/is (seq result)))) + (t/testing "a close after a curve already landing on the start is not materialized twice" + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to + :params {:c1x 3.0 :c1y -2.0 + :c2x 6.0 :c2y -2.0 + :x 10.0 :y 0.0}} + {:command :curve-to + :params {:c1x 6.0 :c1y 2.0 + :c2x 3.0 :c2y 2.0 + :x 0.0 :y 0.0}} + {:command :close-path :params {}}] + result (path.subpath/close-subpaths content)] + (t/is (= [:move-to :curve-to :curve-to] (mapv :command result))) + ;; Rendering/persistence can still recover the explicit SVG close. + (t/is (= content (path.subpath/close-loops content))))) (t/testing "two open fragments that form a closed loop get merged" ;; fragment A: 0,0 → 5,0 ;; fragment B: 10,0 → 5,0 (reversed, connects to A's end) @@ -667,6 +683,65 @@ result (path.subpath/close-subpaths content)] (t/is (seq result))))) +(t/deftest subpath-close-loops + (t/testing "trailing line-to landing on the subpath start becomes a close-path" + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 0.0 :y 0.0}}] + result (path.subpath/close-loops content)] + (t/is (= [:move-to :line-to :line-to :close-path] (mapv :command result))))) + + (t/testing "coincident endpoints within tolerance also close" + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 0.05 :y 0.0}}] + result (path.subpath/close-loops content)] + (t/is (= [:move-to :line-to :close-path] (mapv :command result))))) + + (t/testing "curve landing on the subpath start keeps the curve and appends a close-path" + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :curve-to :params {:c1x 10.0 :c1y 5.0 :c2x 5.0 :c2y 5.0 :x 0.0 :y 0.0}}] + result (path.subpath/close-loops content)] + (t/is (= [:move-to :line-to :curve-to :close-path] (mapv :command result))))) + + (t/testing "already command-closed content is unchanged" + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :close-path :params {}}] + result (path.subpath/close-loops content)] + (t/is (= content (vec result))))) + + (t/testing "open subpaths are left untouched" + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}}] + result (path.subpath/close-loops content)] + (t/is (= content (vec result))))) + + (t/testing "multi-subpath content closes only the coincident loops" + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 0.0 :y 0.0}} + {:command :move-to :params {:x 20.0 :y 20.0}} + {:command :line-to :params {:x 30.0 :y 20.0}}] + result (path.subpath/close-loops content)] + (t/is (= [:move-to :line-to :close-path :move-to :line-to] + (mapv :command result)))))) + +(t/deftest path-close-loops-path-data + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 0.0 :y 0.0}}]) + result (path/close-loops content)] + (t/is (path.impl/path-data? result)) + (t/is (= [:move-to :line-to :line-to :close-path] + (mapv :command (vec result)))))) + (t/deftest subpath-merge-touching-subpaths (t/testing "adjacent subpaths sharing an endpoint collapse into one chain" ;; Heroicons-style fragment: continuous polyline split as M-L M-L M-L @@ -785,6 +860,70 @@ (t/is (= 3.0 (get-in cmd [:params :c1x]))) (t/is (= 7.0 (get-in cmd [:params :c2x]))))) +(t/deftest segment-make-curve-point-keeps-neighbors-corners + ;; Curving a node leaves its neighbours as corners. + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 100.0 :y 0.0}} + {:command :line-to :params {:x 200.0 :y 0.0}}]] + + (t/testing "curving the first (endpoint) node keeps its neighbour a corner" + (let [r (vec (seq (path/make-curve-point content (gpt/point 0.0 0.0)))) + seg1 (get r 1)] + (t/is (= :curve-to (:command seg1))) + ;; The neighbour's handle stays collapsed. + (t/is (= 100.0 (get-in seg1 [:params :c2x]))) + (t/is (= 0.0 (get-in seg1 [:params :c2y]))) + ;; The selected node gets a handle. + (t/is (not= 0.0 (get-in seg1 [:params :c1x]))))) + + (t/testing "curving the last node keeps its neighbour a corner" + (let [r (vec (seq (path/make-curve-point content (gpt/point 200.0 0.0)))) + seg2 (get r 2)] + (t/is (= :curve-to (:command seg2))) + ;; The neighbour's handle stays collapsed. + (t/is (= 100.0 (get-in seg2 [:params :c1x]))) + (t/is (= 0.0 (get-in seg2 [:params :c1y]))))) + + (t/testing "curving a middle node keeps both neighbours corners" + (let [r (vec (seq (path/make-curve-point content (gpt/point 100.0 0.0)))) + seg1 (get r 1) + seg2 (get r 2)] + (t/is (= 0.0 (get-in seg1 [:params :c1x]))) + (t/is (= 200.0 (get-in seg2 [:params :c2x]))))))) + +(t/deftest segment-make-curve-point-acute-corner-is-smooth + ;; Acute corners get equal and opposite handles. + (let [content [{:command :move-to :params {:x 10.0 :y 1.0}} + {:command :line-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y -1.0}}] + r (vec (seq (path/make-curve-point (path/content content) + (gpt/point 0.0 0.0)))) + ;; Read the node's incoming and outgoing handles. + c2y (get-in (get r 1) [:params :c2y]) + c1y (get-in (get r 2) [:params :c1y])] + ;; Both handles extend from the node. + (t/is (not (zero? c2y))) + ;; The node is the midpoint between equal-length handles. + (t/is (= c2y (- c1y))))) + +(t/deftest segment-make-curve-point-closed-seam-follows-neighbour-tangent + ;; Closed seams follow the chord between their neighbours. + (let [point (gpt/point 0.0 0.0) + content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y -8.0}} + {:command :line-to :params {:x 20.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 6.0}} + {:command :line-to :params {:x 0.0 :y 0.0}} + {:command :close-path :params {}}] + result (vec (path/make-curve-point (path/content content) point)) + outgoing (get result 1) + incoming (get result 4)] + ;; Seam handles lie on the chord and oppose each other. + (t/is (mth/close? 0.0 (get-in outgoing [:params :c1x]) 0.001)) + (t/is (mth/close? 0.0 (get-in incoming [:params :c2x]) 0.001)) + (t/is (neg? (get-in outgoing [:params :c1y]))) + (t/is (pos? (get-in incoming [:params :c2y]))))) + (t/deftest helpers-prefix->coords (t/is (= [:c1x :c1y] (path.helpers/prefix->coords :c1))) (t/is (= [:c2x :c2y] (path.helpers/prefix->coords :c2))) @@ -794,15 +933,22 @@ (t/testing "returns point unchanged when from-point is nil" (let [pt (gpt/point 5.0 3.0)] (t/is (= pt (path.helpers/position-fixed-angle pt nil))))) - (t/testing "snaps to nearest 45-degree angle" - (let [from (gpt/point 0 0) - ;; Angle ~30° from from, should snap to 45° - to (gpt/point 10 6) - snapped (path.helpers/position-fixed-angle to from)] - ;; result should have same distance - (let [d-orig (gpt/distance to from) - d-snapped (gpt/distance snapped from)] - (t/is (mth/close? d-orig d-snapped 0.01)))))) + (t/testing "snaps to nearest 15-degree angle" + (let [from (gpt/point 0 0) + ;; ~31° from `from`: snaps to 30° (15° granularity), not 45° + to (gpt/point 10 6) + snapped (path.helpers/position-fixed-angle to from) + d-orig (gpt/distance to from) + d-snapped (gpt/distance snapped from) + snap-ang (gpt/angle snapped from) + orig-ang (gpt/angle to from) + delta (let [d (mod (- snap-ang orig-ang) 360)] (min d (- 360 d)))] + ;; distance preserved + (t/is (mth/close? d-orig d-snapped 0.01)) + ;; snapped onto a 15° multiple + (t/is (let [m (mod snap-ang 15)] (or (< m 0.01) (> m 14.99)))) + ;; Stay within half a 15° bucket of the input angle. + (t/is (<= delta 7.5))))) (t/deftest helpers-command->line (let [prev {:command :move-to :params {:x 0.0 :y 0.0}} @@ -821,6 +967,18 @@ (t/is (= (gpt/point 3.0 5.0) h1)) (t/is (= (gpt/point 7.0 5.0) h2)))) +(t/deftest helpers-entry->bezier + (let [from (gpt/point 0 0) + to (gpt/point 10 0) + line {:from from :to to :segment {:command :line-to}} + curve {:from from + :to to + :segment {:command :curve-to + :params {:x 10 :y 0 :c1x 3 :c1y 5 :c2x 7 :c2y 5}}}] + (t/is (= [from to from to] (path.helpers/entry->bezier line))) + (t/is (= [from to (gpt/point 3 5) (gpt/point 7 5)] + (path.helpers/entry->bezier curve))))) + (t/deftest helpers-line-values (let [from (gpt/point 0.0 0.0) to (gpt/point 10.0 0.0) @@ -1102,6 +1260,637 @@ ;; should have fewer segments (t/is (< (count result) (count simple-open-content))))) +(t/deftest helpers-fit-cubic-recovers-curve + ;; fitting samples of a known cubic recovers control points close to it + (let [curve [(gpt/point 0.0 0.0) (gpt/point 30.0 0.0) + (gpt/point 10.0 10.0) (gpt/point 20.0 10.0)] + samples (mapv #(path.helpers/curve-values curve (/ % 20.0)) (range 21)) + tan1 (path.helpers/curve-tangent curve 0) + tan2 (gpt/negate (path.helpers/curve-tangent curve 1)) + [h1 h2] (path.fit/fit-cubic samples tan1 tan2)] + (t/is (mth/close? 10.0 (:x h1) 1.0)) + (t/is (mth/close? 10.0 (:y h1) 1.0)) + (t/is (mth/close? 20.0 (:x h2) 1.0)) + (t/is (mth/close? 10.0 (:y h2) 1.0)))) + +(t/deftest helpers-curve-closest-t + ;; A degenerate cubic maps points back onto the same line. + (let [curve [(gpt/point 0.0 0.0) (gpt/point 10.0 0.0) + (gpt/point 0.0 0.0) (gpt/point 10.0 0.0)]] + (t/is (mth/close? 0.5 (path.helpers/curve-closest-t curve (gpt/point 5.0 0.0) 0.001) 0.01)) + (doseq [q [(gpt/point 2.5 0.0) (gpt/point 7.0 0.0)]] + (let [t (path.helpers/curve-closest-t curve q 0.001) + p (path.helpers/curve-values curve t)] + (t/is (mth/close? (:x q) (:x p) 0.05)))))) + +(t/deftest helpers-bend-curve-deltas-passes-through-target + ;; the handle deltas move the point at t exactly onto the target, for any t + (let [curve [(gpt/point 0.0 0.0) (gpt/point 10.0 0.0) + (gpt/point 0.0 0.0) (gpt/point 10.0 0.0)]] + (doseq [t [0.3 0.5 0.7] + target [(gpt/point 5.0 4.0) (gpt/point 3.0 -6.0)]] + (let [{:keys [c1x c1y c2x c2y]} (path.helpers/bend-curve-deltas curve t target) + bent [(gpt/point 0.0 0.0) (gpt/point 10.0 0.0) + (gpt/point c1x c1y) (gpt/point (+ 10.0 c2x) c2y)] + p (path.helpers/curve-values bent t)] + (t/is (mth/close? (:x target) (:x p) 0.001)) + (t/is (mth/close? (:y target) (:y p) 0.001)))))) + +(t/deftest segment-flip-content-horizontal + ;; mirror every node across the bbox center on the vertical axis + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}}]) + result (path/flip-content content #{0 1 2} :horizontal) + pts (mapv (comp (juxt :x :y) :params) (vec result))] + (t/is (= [[10.0 0.0] [0.0 0.0] [0.0 10.0]] pts)))) + +(t/deftest segment-flip-content-curve-handles + ;; a curve mirrors its anchors and both handles, keeping shape symmetry + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to :params {:c1x 2.0 :c1y 5.0 :c2x 8.0 :c2y 5.0 :x 10.0 :y 0.0}}]) + result (vec (path/flip-content content #{0 1} :horizontal))] + (t/is (= {:x 10.0 :y 0.0} (:params (first result)))) + (t/is (= {:c1x 8.0 :c1y 5.0 :c2x 2.0 :c2y 5.0 :x 0.0 :y 0.0} + (:params (second result)))))) + +(t/deftest segment-flip-content-vertical + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to :params {:c1x 2.0 :c1y 5.0 :c2x 8.0 :c2y 5.0 :x 10.0 :y 0.0}}]) + result (vec (path/flip-content content #{0 1} :vertical))] + (t/is (= {:c1x 2.0 :c1y -5.0 :c2x 8.0 :c2y -5.0 :x 10.0 :y 0.0} + (:params (second result)))))) + +(t/deftest segment-separate-single-node + ;; Separating an interior node creates two open ends. + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}]) + result (vec (path/separate-nodes content #{(gpt/point 10.0 0.0)}))] + ;; move-to, line-to (to node1 kept at 10,0), move-to (node2 offset), line-to + (t/is (= [:move-to :line-to :move-to :line-to] (mapv :command result))) + (t/is (= {:x 10.0 :y 0.0} (:params (nth result 1)))) + (t/is (= {:x 18.0 :y 8.0} (:params (nth result 2)))) + (t/is (= {:x 20.0 :y 0.0} (:params (nth result 3)))))) + +(t/deftest segment-separate-single-node-custom-offset + ;; The supplied offset controls the gap between split ends. + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}]) + result (vec (path/separate-nodes content #{(gpt/point 10.0 0.0)} (gpt/point 2.0 2.0)))] + (t/is (= [:move-to :line-to :move-to :line-to] (mapv :command result))) + (t/is (= {:x 10.0 :y 0.0} (:params (nth result 1)))) + (t/is (= {:x 12.0 :y 2.0} (:params (nth result 2)))) + (t/is (= {:x 20.0 :y 0.0} (:params (nth result 3)))))) + +(t/deftest segment-separate-single-node-closed-seam + ;; Separating a closed seam creates two endpoints. + (let [point (gpt/point 0.0 0.0) + content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to + :params {:c1x 2.0 :c1y -2.0 + :c2x 8.0 :c2y -2.0 + :x 10.0 :y 0.0}} + {:command :curve-to + :params {:c1x 8.0 :c1y 2.0 + :c2x 2.0 :c2y 2.0 + :x 0.0 :y 0.0}} + {:command :close-path :params {}}]) + result (vec (path/separate-nodes content #{point} (gpt/point 2.0 2.0)))] + (t/is (= [:move-to :curve-to :curve-to] (mapv :command result))) + (t/is (= {:x 0.0 :y 0.0} + (select-keys (:params (first result)) [:x :y]))) + (t/is (= {:x 2.0 :y 2.0} + (select-keys (:params (peek result)) [:x :y]))) + ;; The incoming c2 stays attached to the shifted endpoint. + (t/is (= {:c2x 4.0 :c2y 4.0} + (select-keys (:params (peek result)) [:c2x :c2y]))))) + +(t/deftest segment-separate-single-node-endpoint-noop + ;; an endpoint node has no following segment, so nothing is split + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}]) + result (vec (path/separate-nodes content #{(gpt/point 20.0 0.0)}))] + (t/is (= [:move-to :line-to :line-to] (mapv :command result))))) + +(t/deftest segment-separate-single-node-curve-carries-handler + ;; the outgoing curve's leading handler is shifted with the new start + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :curve-to :params {:c1x 12.0 :c1y 0.0 :c2x 18.0 :c2y 0.0 :x 20.0 :y 0.0}}]) + result (vec (path/separate-nodes content #{(gpt/point 10.0 0.0)})) + curve (nth result 3)] + (t/is (= [:move-to :line-to :move-to :curve-to] (mapv :command result))) + (t/is (= {:x 18.0 :y 8.0} (:params (nth result 2)))) + ;; c1 shifted by the same (8,8) offset, c2/end untouched + (t/is (= 20.0 (get-in curve [:params :c1x]))) + (t/is (= 8.0 (get-in curve [:params :c1y]))) + (t/is (= 18.0 (get-in curve [:params :c2x]))) + (t/is (= 20.0 (get-in curve [:params :x]))))) + +(t/deftest segment-separate-single-node-junction + ;; Separating coincident subpaths creates one open end per line. + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 5.0 :y 5.0}} + {:command :move-to :params {:x 5.0 :y 5.0}} + {:command :line-to :params {:x 10.0 :y 10.0}}]) + result (vec (path/separate-nodes content #{(gpt/point 5.0 5.0)}))] + (t/is (= [:move-to :line-to :move-to :line-to] (mapv :command result))) + ;; the first line keeps (5,5); the second subpath's start is offset by (8,8) + (t/is (= {:x 5.0 :y 5.0} (:params (nth result 1)))) + (t/is (= {:x 13.0 :y 13.0} (:params (nth result 2)))) + (t/is (= {:x 10.0 :y 10.0} (:params (nth result 3)))))) + +(t/deftest segment-separate-single-node-junction-three-lines + ;; three lines meeting at a point separate into three distinct offset ends + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 5.0 :y 5.0}} + {:command :move-to :params {:x 20.0 :y 0.0}} + {:command :line-to :params {:x 5.0 :y 5.0}} + {:command :move-to :params {:x 5.0 :y 5.0}} + {:command :line-to :params {:x 10.0 :y 10.0}}]) + result (vec (path/separate-nodes content #{(gpt/point 5.0 5.0)}))] + (t/is (= [{:x 0.0 :y 0.0} {:x 5.0 :y 5.0} + {:x 20.0 :y 0.0} {:x 13.0 :y 13.0} + {:x 21.0 :y 21.0} {:x 10.0 :y 10.0}] + (mapv #(select-keys (:params %) [:x :y]) result))))) + +(t/deftest segment-flip-content-partial-selection + ;; only the selected nodes and their handles move; others stay put + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}}]) + result (path/flip-content content #{0 1} :horizontal) + pts (mapv (comp (juxt :x :y) :params) (vec result))] + (t/is (= [[10.0 0.0] [0.0 0.0] [10.0 10.0]] pts)))) + +(t/deftest segment-align-content + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 2.0}} + {:command :line-to :params {:x 4.0 :y 20.0}}]) + pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))] + ;; align to the left edge: every selected x becomes the min x + (t/is (= [[0.0 0.0] [0.0 2.0] [0.0 20.0]] + (pts (path/align-content content #{0 1 2} :hleft)))) + ;; align to horizontal center: x becomes the bbox center + (t/is (= [[5.0 0.0] [5.0 2.0] [5.0 20.0]] + (pts (path/align-content content #{0 1 2} :hcenter)))) + ;; align to the top edge: every selected y becomes the min y + (t/is (= [[0.0 0.0] [10.0 0.0] [4.0 0.0]] + (pts (path/align-content content #{0 1 2} :vtop)))))) + +(t/deftest segment-align-content-partial-and-guard + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 2.0}} + {:command :line-to :params {:x 4.0 :y 20.0}}]) + pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))] + ;; only the selected nodes align; the unselected node stays put + (t/is (= [[0.0 0.0] [0.0 2.0] [4.0 20.0]] + (pts (path/align-content content #{0 1} :hleft)))) + ;; fewer than two selected nodes is a no-op + (t/is (= (pts content) + (pts (path/align-content content #{0} :hleft)))))) + +(t/deftest segment-align-content-moves-handles + ;; a selected node's attached handles move rigidly with its anchor + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to :params {:c1x 2.0 :c1y 1.0 :c2x 8.0 :c2y 1.0 :x 10.0 :y 0.0}}]) + result (vec (path/align-content content #{0 1} :vtop))] + ;; both nodes already share y=0, so vtop is a no-op on the anchors and + ;; leaves the handles untouched + (t/is (= {:c1x 2.0 :c1y 1.0 :c2x 8.0 :c2y 1.0 :x 10.0 :y 0.0} + (:params (second result)))))) + +(t/deftest segment-set-nodes-coordinate + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}])] + ;; setting x for two nodes moves each to that x (per-node delta), the + ;; unselected node is untouched + (t/is (= [[:move-to {:x 5.0 :y 0.0}] + [:line-to {:x 10.0 :y 0.0}] + [:line-to {:x 5.0 :y 0.0}]] + (mapv (juxt :command :params) + (vec (path/set-nodes-coordinate content #{0 2} :x 5.0))))) + ;; a single node's y moves only that node, and its attached handle moves + ;; rigidly with the anchor + (let [curved (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to :params {:c1x 2.0 :c1y 0.0 :c2x 8.0 :c2y 0.0 :x 10.0 :y 0.0}}]) + r (vec (path/set-nodes-coordinate curved #{1} :y 5.0))] + ;; node 1 anchor y 0 -> 5 (delta +5); its :c2 handle (owned by node 1) + ;; moves +5 too; :c1 (owned by node 0, unselected) stays + (t/is (= {:c1x 2.0 :c1y 0.0 :c2x 8.0 :c2y 5.0 :x 10.0 :y 5.0} + (:params (second r))))))) + +(t/deftest segment-set-nodes-coordinate-keeps-coincident-nodes-together + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 0.0 :y 0.0}}]) + result (vec (path/set-nodes-coordinate content #{0} :y 5.0))] + ;; The first and last commands are the same logical closed-seam node. + (t/is (= (gpt/point 0.0 5.0) + (path.helpers/segment->point (nth result 0)))) + (t/is (= (gpt/point 0.0 5.0) + (path.helpers/segment->point (nth result 2)))) + (t/is (= (gpt/point 10.0 0.0) + (path.helpers/segment->point (nth result 1)))))) + +(t/deftest segment-align-content-coincident-nodes + (t/testing "align-content groups coincident nodes with sub-epsilon coordinate differences" + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0001 :y 2.0}} + {:command :line-to :params {:x 10.0 :y 20.0}}]) + pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))] + ;; Nodes at ~10.0 should be grouped together for alignment + (t/is (= [[0.0 0.0] [0.0 2.0] [0.0 20.0]] + (pts (path/align-content content #{0 1 2} :hleft))))))) + +(t/deftest segment-flip-content-coincident-nodes + (t/testing "flip-content handles coincident nodes with sub-epsilon coordinate differences" + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0001 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}}]) + pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c))) + result (path/flip-content content #{0 1 2} :horizontal)] + ;; All nodes should flip across the horizontal center. + ;; Floating-point imprecision from the 10.0001 input is expected. + (let [[[x0 y0] [x1 y1] [x2 y2]] (pts result)] + (t/is (mth/close? 10.0 x0 0.001)) + (t/is (mth/close? 0.0 y0 0.001)) + (t/is (mth/close? 0.0 x1 0.001)) + (t/is (mth/close? 0.0 y1 0.001)) + (t/is (mth/close? 0.0 x2 0.001)) + (t/is (mth/close? 10.0 y2 0.001)))))) + +(t/deftest segment-set-handler-points + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to :params {:c1x 2.0 :c1y 2.0 :c2x 8.0 :c2y 2.0 :x 10.0 :y 0.0}}]) + r (vec (path/set-handler-points content {[1 :c2] (gpt/point 7.0 6.0)}))] + ;; c2 set to the target point; c1 and the anchor stay put + (t/is (= {:c1x 2.0 :c1y 2.0 :c2x 7.0 :c2y 6.0 :x 10.0 :y 0.0} + (:params (second r)))))) + +(t/deftest segment-translate-selected-nodes + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}]) + ;; translate nodes 1 and 2 by (0, 5): both move down, node 0 stays + r (vec (path/translate-selected-nodes content #{1 2} (gpt/point 0.0 5.0)))] + (t/is (= [[:move-to {:x 0.0 :y 0.0}] + [:line-to {:x 10.0 :y 5.0}] + [:line-to {:x 20.0 :y 5.0}]] + (mapv (juxt :command :params) r))))) + +(t/deftest segment-distribute-content + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 3.0 :y 5.0}} + {:command :line-to :params {:x 10.0 :y 9.0}}]) + pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))] + ;; the middle node is spaced evenly between the two extremes on x + (t/is (= [[0.0 0.0] [5.0 5.0] [10.0 9.0]] + (pts (path/distribute-content content #{0 1 2} :horizontal)))) + ;; fewer than three selected nodes is a no-op + (t/is (= (pts content) + (pts (path/distribute-content content #{0 1} :horizontal)))))) + +(t/deftest segment-distribute-content-keeps-coincident-nodes-together + ;; Coincident selected nodes move as one group. + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 3.0 :y 7.0}} + {:command :line-to :params {:x 3.0 :y 7.0}} + {:command :line-to :params {:x 10.0 :y 0.0}}]) + pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))] + ;; three distinct positions (0, 3, 10); the coincident pair is one group + ;; centred at x=5 and both nodes move there together, staying coincident + (t/is (= [[0.0 0.0] [5.0 7.0] [5.0 7.0] [10.0 0.0]] + (pts (path/distribute-content content #{0 1 2 3} :horizontal)))) + ;; only two distinct positions among the selection is a no-op + (t/is (= (pts content) + (pts (path/distribute-content content #{1 2 3} :horizontal)))))) + +(t/deftest segment-distribute-content-with-floating-point-coordinates + (t/testing "distribute-content groups nodes with floating-point rounding differences" + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 3.0001 :y 7.0}} + {:command :line-to :params {:x 3.0002 :y 7.0}} + {:command :line-to :params {:x 10.0 :y 0.0}}]) + pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))] + ;; Nodes at ~3.0 should be grouped together + (t/is (= [[0.0 0.0] [5.0 7.0] [5.0 7.0] [10.0 0.0]] + (pts (path/distribute-content content #{0 1 2 3} :horizontal))))))) + +(t/deftest helpers-curve-arc-length-t + (let [arc-len (fn [curve a b] + (->> (range 1001) + (map #(path.helpers/curve-values + curve (+ a (* (/ (double %) 1000) (- b a))))) + (partition 2 1) + (map (fn [[p q]] (gpt/distance p q))) + (reduce +)))] + ;; a straight line (degenerate cubic) has its visual middle at t=0.5 + (let [line [(gpt/point 0.0 0.0) (gpt/point 10.0 0.0) + (gpt/point 0.0 0.0) (gpt/point 10.0 0.0)]] + (t/is (mth/close? 0.5 (path.helpers/curve-arc-length-t line) 0.01))) + ;; An uneven curve's arc midpoint differs from its parametric midpoint. + (let [curve [(gpt/point 0.0 0.0) (gpt/point 100.0 100.0) + (gpt/point 0.0 0.0) (gpt/point 0.0 100.0)] + t (path.helpers/curve-arc-length-t curve) + total (arc-len curve 0.0 1.0) + first-half (arc-len curve 0.0 t)] + (t/is (< 0.5 t 1.0)) + ;; the length up to t is within 1% of half the total + (t/is (< (mth/abs (- first-half (/ total 2.0))) (* 0.01 total)))))) + +(t/deftest helpers-fit-curve-single-curve + ;; samples of one gentle cubic are fitted back with a single curve + (let [curve [(gpt/point 0.0 0.0) (gpt/point 30.0 0.0) + (gpt/point 10.0 10.0) (gpt/point 20.0 10.0)] + samples (mapv #(path.helpers/curve-values curve (/ % 24.0)) (range 25)) + result (path.fit/fit-curve samples 0.5)] + (t/is (= 1 (count result))) + (let [[start end h1 h2] (first result)] + (t/is (= (gpt/point 0.0 0.0) start)) + (t/is (= (gpt/point 30.0 0.0) end)) + (t/is (mth/close? 10.0 (:x h1) 1.5)) + (t/is (mth/close? 10.0 (:y h1) 1.5)) + (t/is (mth/close? 20.0 (:x h2) 1.5)) + (t/is (mth/close? 10.0 (:y h2) 1.5))))) + +(t/deftest helpers-fit-curve-splits-and-chains + ;; Sharp corners split the fit into chained curves. + (let [pts (into [] + (concat + (map #(gpt/point (double %) (double %)) (range 0 11)) + (map #(gpt/point (+ 10.0 %) (- 10.0 %)) (range 1 11)))) + result (path.fit/fit-curve pts 0.1)] + (t/is (> (count result) 1)) + (t/is (every? (fn [[c1 c2]] (= (nth c1 1) (nth c2 0))) + (map vector result (rest result)))) + (t/is (= (gpt/point 0.0 0.0) (get-in result [0 0]))) + (t/is (= (gpt/point 20.0 0.0) (nth (peek result) 1))))) + +(t/deftest helpers-fit-curve-respects-tolerance + ;; every input point stays within tolerance of the fitted sequence + (let [pts (mapv #(gpt/point (double %) (* 5.0 (mth/sin (/ % 3.0)))) + (range 0 31)) + tol 0.5 + result (path.fit/fit-curve pts tol) + curve-pts (into [] + (mapcat (fn [c] + (map #(path.helpers/curve-values c (/ % 100.0)) + (range 101)))) + result) + max-dev (reduce max + (map (fn [p] + (reduce min (map #(gpt/distance p %) curve-pts))) + pts))] + (t/is (<= max-dev (+ tol 0.05))))) + +(t/deftest helpers-fit-curve-keeps-sharp-corners + ;; Sharp-corner handles follow their own legs. + (let [corner (gpt/point 10.0 10.0) + ;; two legs meeting at a 90 degree corner: (0,0)->(10,10)->(20,0) + pts (into [] + (concat + (map #(gpt/point (double %) (double %)) (range 0 11)) + (map #(gpt/point (+ 10.0 %) (- 10.0 %)) (range 1 11)))) + result (path.fit/fit-curve pts 0.1) + ;; the two curves meeting at the corner + left (first (filter #(= corner (nth % 1)) result)) + right (first (filter #(= corner (nth % 0)) result)) + v-in (gpt/to-vec corner (nth left 3)) ;; incoming handle (h2) direction + v-out (gpt/to-vec corner (nth right 2)) ;; outgoing handle (h1) direction + angle (gpt/angle-with-other v-in v-out)] + (t/is (some? left)) + (t/is (some? right)) + ;; The join keeps the corner's angle. + (t/is (< angle 135.0)) + (t/is (mth/close? 90.0 angle 15.0)))) + +(t/deftest segment-smooth-points->content + (t/testing "two points produce a straight segment" + (let [content (path.segment/smooth-points->content + [(gpt/point 0.0 0.0) (gpt/point 10.0 0.0)] 1.0)] + (t/is (= [:move-to :line-to] (mapv :command content))))) + (t/testing "freehand-like points produce fewer, fitted curve segments" + (let [pts (mapv #(gpt/point (double %) (* 5.0 (mth/sin (/ % 3.0)))) + (range 0 31)) + content (path.segment/smooth-points->content pts 1.0) + cmds (mapv :command content)] + (t/is (= :move-to (first cmds))) + (t/is (every? #(= :curve-to %) (rest cmds))) + (t/is (< (count cmds) (count pts))) + (t/is (= {:x 0.0 :y 0.0} (:params (first (vec content))))) + (let [last-params (:params (peek (vec content)))] + (t/is (mth/close? 30.0 (:x last-params))) + (t/is (mth/close? (* 5.0 (mth/sin 10.0)) (:y last-params))))))) + +(t/deftest segment-remove-nodes-collinear-keeps-line + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}] + result (path.segment/remove-nodes (path/content content) + #{(gpt/point 10.0 0.0)})] + (t/is (= [:move-to :line-to] (mapv :command result))) + (t/is (= {:x 20.0 :y 0.0} (:params (second result)))))) + +(t/deftest segment-remove-nodes-corner-fits-curve + ;; Removing a slanted corner keeps both endpoint tangents. + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}] + result (path.segment/remove-nodes (path/content content) + #{(gpt/point 10.0 10.0)}) + curve (second result)] + (t/is (= [:move-to :curve-to] (mapv :command result))) + (let [{:keys [c1x c1y c2x c2y x y]} (:params curve) + mid (path.helpers/curve-values (gpt/point 0.0 0.0) (gpt/point x y) + (gpt/point c1x c1y) (gpt/point c2x c2y) 0.5)] + (t/is (mth/close? 20.0 x)) + (t/is (mth/close? 0.0 y)) + ;; handlers stay on the removed segments' directions (45 degrees) + (t/is (mth/close? c1x c1y 0.01)) + (t/is (mth/close? (- 20.0 c2x) c2y 0.01)) + ;; the curve bulges towards the removed corner + (t/is (< 2.0 (:y mid) 10.0)) + (t/is (mth/close? 10.0 (:x mid) 0.5))))) + +(t/deftest segment-remove-nodes-between-curves-approximates + ;; Joined quarter arcs collapse into a fitted semicircle. + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to :params {:c1x 0.0 :c1y 5.52 :c2x 4.48 :c2y 10.0 :x 10.0 :y 10.0}} + {:command :curve-to :params {:c1x 15.52 :c1y 10.0 :c2x 20.0 :c2y 5.52 :x 20.0 :y 0.0}}] + result (path.segment/remove-nodes (path/content content) + #{(gpt/point 10.0 10.0)}) + curve (second result)] + (t/is (= [:move-to :curve-to] (mapv :command result))) + (let [{:keys [c1x c1y c2x c2y x y]} (:params curve) + mid (path.helpers/curve-values (gpt/point 0.0 0.0) (gpt/point x y) + (gpt/point c1x c1y) (gpt/point c2x c2y) 0.5)] + ;; The fitted curve keeps the semicircle apex. + (t/is (mth/close? 10.0 (:x mid) 0.5)) + (t/is (mth/close? 10.0 (:y mid) 0.5))))) + +(t/deftest segment-remove-node-restores-a-split-curve + ;; Removing an untouched split node rejoins the cubic exactly. + (let [from (gpt/point 0.0 0.0) + original {:command :curve-to + :params {:c1x 0.0 :c1y 0.0 + :c2x 0.0 :c2y 100.0 + :x 100.0 :y 100.0}} + content (path/content [(path.helpers/make-move-to from) original]) + curve (path.helpers/command->bezier original from) + t-val (path.helpers/curve-arc-length-t curve) + split (-> (path.segment/split-segments content #{from (gpt/point 100.0 100.0)} t-val) + (path/content)) + inserted (path.helpers/segment->point (nth split 1)) + result (vec (path.segment/remove-nodes split #{inserted})) + healed (second result)] + ;; Split at the asymmetric curve's arc midpoint. + (t/is (not (mth/close? 0.5 t-val 0.01))) + (t/is (= [:move-to :curve-to] (mapv :command result))) + (doseq [coord [:c1x :c1y :c2x :c2y :x :y]] + (t/is (mth/close? (get-in original [:params coord]) + (get-in healed [:params coord])))))) + +(t/deftest segment-remove-nodes-multiple-consecutive + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 5.0 :y 5.0}} + {:command :line-to :params {:x 10.0 :y 7.0}} + {:command :line-to :params {:x 15.0 :y 5.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}] + result (path.segment/remove-nodes (path/content content) + #{(gpt/point 5.0 5.0) + (gpt/point 10.0 7.0) + (gpt/point 15.0 5.0)}) + curve (second result)] + (t/is (= [:move-to :curve-to] (mapv :command result))) + (let [{:keys [c1x c1y c2x c2y x y]} (:params curve) + mid (path.helpers/curve-values (gpt/point 0.0 0.0) (gpt/point x y) + (gpt/point c1x c1y) (gpt/point c2x c2y) 0.5)] + (t/is (mth/close? 10.0 (:x mid) 1.0)) + (t/is (< 4.0 (:y mid) 8.5))))) + +(t/deftest segment-remove-nodes-endpoints-drop-segments + (let [content (path/content simple-open-content)] + (t/testing "removing the first node drops the leading segment" + (let [result (path.segment/remove-nodes content #{(gpt/point 0.0 0.0)})] + (t/is (= [:move-to :line-to] (mapv :command result))) + (t/is (= {:x 10.0 :y 0.0} (:params (first result)))))) + (t/testing "removing the last node drops the trailing segment" + (let [result (path.segment/remove-nodes content #{(gpt/point 10.0 10.0)})] + (t/is (= [:move-to :line-to] (mapv :command result))) + (t/is (= {:x 10.0 :y 0.0} (:params (second result)))))))) + +(t/deftest segment-remove-nodes-closed-path-keeps-closure + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 20.0 :y 0.0}} + {:command :line-to :params {:x 0.0 :y 0.0}} + {:command :close-path :params {}}] + result (path.segment/remove-nodes (path/content content) + #{(gpt/point 10.0 10.0)})] + (t/is (= [:move-to :curve-to :line-to :close-path] (mapv :command result))))) + +(t/deftest segment-remove-nodes-heals-a-closed-seam + (let [line-closed [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 0.0 :y 10.0}} + {:command :line-to :params {:x 0.0 :y 0.0}}] + command-closed (conj line-closed {:command :close-path :params {}}) + seam (gpt/point 0.0 0.0)] + (doseq [content [line-closed command-closed]] + (let [result (vec (path.segment/remove-nodes (path/content content) #{seam})) + commands (mapv :command result) + points (mapv path.helpers/segment->point + (remove #(= :close-path (:command %)) result))] + ;; Both non-seam sides survive and are joined through one fitted segment. + (t/is (= [:move-to :line-to :line-to :curve-to] + (cond-> commands + (= :close-path (peek commands)) pop))) + (t/is (= [(gpt/point 10.0 0.0) + (gpt/point 10.0 10.0) + (gpt/point 0.0 10.0) + (gpt/point 10.0 0.0)] + points)))))) + +(t/deftest segment-remove-nodes-heals-a-touching-subpath-seam + ;; During path edition, duplicated and merged halves can still be stored as + ;; two open subpaths whose endpoints touch. Finalizing the path joins them, + ;; but deleting their shared node must behave the same before finalization. + (let [content + (path/content + [{:command :move-to :params {:x 0.0 :y 10.0}} + {:command :line-to :params {:x -10.0 :y 7.0}} + {:command :line-to :params {:x -10.0 :y 3.0}} + {:command :line-to :params {:x 0.0 :y 0.0}} + {:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 3.0}} + {:command :line-to :params {:x 10.0 :y 7.0}} + {:command :line-to :params {:x 0.0 :y 10.0}}]) + result (vec (path.segment/remove-nodes content #{(gpt/point 0.0 10.0)}))] + (t/is (= [:move-to :line-to :line-to :line-to :line-to :curve-to] + (mapv :command result))) + (t/is (= (gpt/point -10.0 7.0) + (path.helpers/segment->point (first result)))) + (t/is (= (gpt/point -10.0 7.0) + (path.helpers/segment->point (peek result)))))) + +(t/deftest segment-remove-nodes-chain-ending-on-close-path + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :close-path :params {}}] + result (path.segment/remove-nodes (path/content content) + #{(gpt/point 10.0 10.0)})] + ;; the geometry back to the start is approximated and the path stays closed + (t/is (= [:move-to :line-to :curve-to :close-path] (mapv :command result))) + (t/is (mth/close? 0.0 (get-in (vec result) [2 :params :x]))) + (t/is (mth/close? 0.0 (get-in (vec result) [2 :params :y]))))) + +(t/deftest segment-remove-nodes-heals-removed-close-target + ;; Removing the closed seam preserves both adjacent sides and fits their + ;; replacement across the former start point. + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 20.0 :y 0.0}} + {:command :line-to :params {:x 0.0 :y 0.0}} + {:command :close-path :params {}}] + result (path.segment/remove-nodes (path/content content) + #{(gpt/point 0.0 0.0)})] + (t/is (= [:move-to :line-to :curve-to :close-path] (mapv :command result))) + (t/is (= (gpt/point 10.0 10.0) + (path.helpers/segment->point (first result)))) + (t/is (= (gpt/point 10.0 10.0) + (path.helpers/segment->point (nth result 2)))))) + (t/deftest segment-join-nodes (let [content (path/content simple-open-content) pt1 (gpt/point 0.0 0.0) @@ -1117,6 +1906,18 @@ ;; separate-nodes should return a collection (vector or seq) (t/is (coll? result)))) +(t/deftest segment-separate-nodes-with-floating-point-coordinates + (t/testing "separate-nodes finds nodes with floating-point rounding differences" + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0001 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}]) + pt (gpt/point 10.0002 0.0) + result (path.segment/separate-nodes content #{pt})] + ;; Should still find and separate the node + (t/is (coll? result)) + (t/is (> (count result) (count content)))))) + (t/deftest segment-make-corner-point (let [content (path/content sample-content-2) ;; Take a curve point and make it a corner @@ -1290,6 +2091,118 @@ (let [result (path/merge-nodes nil #{(gpt/point 0 0)})] (t/is (some? result))))) +(t/deftest path-merge-disconnected-nodes + ;; Merging separate subpaths joins them at the shared midpoint. + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :move-to :params {:x 0.0 :y 10.0}} + {:command :line-to :params {:x 10.0 :y 10.0}}]) + pts #{(gpt/point 10.0 0.0) (gpt/point 0.0 10.0)} + result (vec (path/merge-nodes content pts))] + (t/is (= [{:x 0.0 :y 0.0} {:x 5.0 :y 5.0} + {:x 5.0 :y 5.0} {:x 10.0 :y 10.0}] + (mapv :params result))))) + +(t/deftest path-duplicate-node-content + ;; Duplicating a node copies its incident segments as subpaths. + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 100.0 :y 0.0}} + {:command :curve-to :params {:x 200.0 :y 100.0 :c1x 100.0 :c1y 50.0 :c2x 150.0 :c2y 100.0}}]) + off (gpt/point 10 10)] + ;; Interior copies meet at the offset node. + (let [{ext :content selected :selected} (path/duplicate-node-content content 1 off)] + (t/is (= [[:move-to {:x 0.0 :y 0.0}] + [:line-to {:x 110.0 :y 10.0}] + [:move-to {:x 200.0 :y 100.0}] + [:curve-to {:x 110.0 :y 10.0 :c1x 150.0 :c1y 100.0 :c2x 110.0 :c2y 60.0}]] + (mapv (juxt :command :params) ext))) + (t/is (= #{1 3} selected))) + ;; Endpoint copies keep only the incoming curve. + (let [{ext :content selected :selected} (path/duplicate-node-content content 2 off)] + (t/is (= [[:move-to {:x 100.0 :y 0.0}] + [:curve-to {:c1x 100.0 :c1y 50.0 :c2x 160.0 :c2y 110.0 :x 210.0 :y 110.0}]] + (mapv (juxt :command :params) ext))) + (t/is (= #{1} selected))) + ;; Subpath-start copies reverse the outgoing segment. + (let [{ext :content selected :selected} (path/duplicate-node-content content 0 off)] + (t/is (= [[:move-to {:x 100.0 :y 0.0}] + [:line-to {:x 10.0 :y 10.0}]] + (mapv (juxt :command :params) ext))) + (t/is (= #{1} selected))) + ;; a lone point (subpath with only a move-to) is copied as an offset point + (let [lone (path/content [{:command :move-to :params {:x 5.0 :y 5.0}}]) + {ext :content selected :selected} (path/duplicate-node-content lone 0 off)] + (t/is (= [[:move-to {:x 15.0 :y 15.0}]] + (mapv (juxt :command :params) ext))) + (t/is (= #{0} selected))))) + +(t/deftest segment-collapse-handler + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to :params {:x 100.0 :y 0.0 :c1x 20.0 :c1y 40.0 :c2x 80.0 :c2y 40.0}}])] + ;; Collapsing one handler keeps the other handle unchanged. + (t/is (= [[:move-to {:x 0.0 :y 0.0}] + [:curve-to {:x 100.0 :y 0.0 :c1x 0.0 :c1y 0.0 :c2x 80.0 :c2y 40.0}]] + (mapv (juxt :command :params) (path/collapse-handler content 1 :c1)))) + ;; collapsing the second handler too degenerates the curve into a line-to + (let [collapsed (-> content + (path/collapse-handler 1 :c1) + (path/collapse-handler 1 :c2))] + (t/is (= [[:move-to {:x 0.0 :y 0.0}] + [:line-to {:x 100.0 :y 0.0}]] + (mapv (juxt :command :params) collapsed)))))) + +(t/deftest segment-toggle-segment-curve + (let [line (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 90.0 :y 0.0}}]) + curve (path/toggle-segment-curve line 1)] + ;; Curved lines use perpendicular bowed handles. + (t/is (= [[:move-to {:x 0.0 :y 0.0}] + [:curve-to {:x 90.0 :y 0.0 :c1x 30.0 :c1y 22.5 :c2x 60.0 :c2y 22.5}]] + (mapv (juxt :command :params) curve))) + ;; curve -> line: drops the control points + (t/is (= [[:move-to {:x 0.0 :y 0.0}] + [:line-to {:x 90.0 :y 0.0}]] + (mapv (juxt :command :params) (path/toggle-segment-curve curve 1)))) + ;; move-to / close-path are untouched + (t/is (= (vec line) (vec (path/toggle-segment-curve line 0)))))) + +(t/deftest segment-remove-segments + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}} + {:command :line-to :params {:x 30.0 :y 0.0}}])] + ;; Removing an interior segment keeps both endpoints. + (t/is (= [[:move-to {:x 0.0 :y 0.0}] + [:line-to {:x 10.0 :y 0.0}] + [:move-to {:x 20.0 :y 0.0}] + [:line-to {:x 30.0 :y 0.0}]] + (mapv (juxt :command :params) (path/remove-segments content #{2})))) + ;; Removing the first segment drops the dangling start node. + (t/is (= [[:move-to {:x 10.0 :y 0.0}] + [:line-to {:x 20.0 :y 0.0}] + [:line-to {:x 30.0 :y 0.0}]] + (mapv (juxt :command :params) (path/remove-segments content #{1})))) + ;; a closed subpath broken elsewhere keeps its closing line geometry + (let [closed (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :close-path :params {}}])] + (t/is (= [[:move-to {:x 10.0 :y 0.0}] + [:line-to {:x 10.0 :y 10.0}] + [:line-to {:x 0.0 :y 0.0}]] + (mapv (juxt :command :params) (path/remove-segments closed #{1})))) + ;; removing the close-path just leaves the subpath open + (t/is (= [[:move-to {:x 0.0 :y 0.0}] + [:line-to {:x 10.0 :y 0.0}] + [:line-to {:x 10.0 :y 10.0}]] + (mapv (juxt :command :params) (path/remove-segments closed #{3}))))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; BOOL OPERATIONS — INTERSECTION / DIFFERENCE / EXCLUSION ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1592,3 +2505,85 @@ [])] (t/is (= max-safe (:x move-res)) "reduce first x should be clamped") (t/is (= min-safe (:y move-res)) "reduce first y should be clamped"))))) + +(t/deftest segment-entries-identity + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :curve-to :params {:c1x 12.0 :c1y 0.0 :c2x 18.0 :c2y 0.0 :x 20.0 :y 0.0}} + {:command :close-path :params {}} + {:command :move-to :params {:x 30.0 :y 30.0}} + {:command :line-to :params {:x 40.0 :y 30.0}}] + entries (path/segment-entries content)] + (t/is (= [1 2 3 5] (mapv :index entries))) + ;; The closing segment goes back to the subpath start node + (t/is (= 0 (:to-index (nth entries 2)))) + (t/is (= (gpt/point 0.0 0.0) (:to (nth entries 2)))) + ;; The second subpath starts from its own move-to + (t/is (= 4 (:from-index (nth entries 3)))))) + +(t/deftest single-line-predicate + ;; A move-to followed by exactly one line-to is a single line + (t/is (path/single-line? + (path/content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}}]))) + ;; A curve, a polyline and a closed loop are not + (t/is (not (path/single-line? + (path/content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to :params {:c1x 1.0 :c1y 0.0 :c2x 2.0 :c2y 0.0 :x 10.0 :y 0.0}}])))) + (t/is (not (path/single-line? + (path/content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}])))) + (t/is (not (path/single-line? nil)))) + +(t/deftest extract-content-chains-and-breaks + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}} + {:command :line-to :params {:x 30.0 :y 0.0}} + {:command :line-to :params {:x 40.0 :y 0.0}}]] + ;; Adjacent selected segments chain into one subpath + (t/is (= [{:command :move-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}} + {:command :line-to :params {:x 30.0 :y 0.0}}] + (vec (path/extract-content content {:segments #{2 3}})))) + ;; A gap starts a new subpath + (t/is (= [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :move-to :params {:x 30.0 :y 0.0}} + {:command :line-to :params {:x 40.0 :y 0.0}}] + (vec (path/extract-content content {:segments #{1 4}})))))) + +(t/deftest extract-content-from-selected-nodes + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :curve-to :params {:c1x 12.0 :c1y 0.0 :c2x 18.0 :c2y 0.0 :x 20.0 :y 0.0}} + {:command :line-to :params {:x 30.0 :y 0.0}}]] + ;; Segments whose two endpoint nodes are selected are included + (t/is (= [{:command :move-to :params {:x 10.0 :y 0.0}} + {:command :curve-to :params {:c1x 12.0 :c1y 0.0 :c2x 18.0 :c2y 0.0 :x 20.0 :y 0.0}}] + (vec (path/extract-content content {:nodes #{1 2}})))) + ;; A single selected node produces no content + (t/is (empty? (path/extract-content content {:nodes #{1}}))) + ;; Non-adjacent selected nodes produce no content + (t/is (empty? (path/extract-content content {:nodes #{0 2}}))))) + +(t/deftest extract-content-closes-full-loops + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :close-path :params {}}] + result (vec (path/extract-content content {:nodes #{0 1 2}}))] + (t/is (= :move-to (:command (nth result 0)))) + (t/is (= :line-to (:command (nth result 1)))) + (t/is (= :line-to (:command (nth result 2)))) + (t/is (= :close-path (:command (nth result 3)))))) + +(t/deftest splice-content-appends-subpaths + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}}] + sub [{:command :move-to :params {:x 30.0 :y 30.0}} + {:command :line-to :params {:x 40.0 :y 30.0}}] + result (path/splice-content content sub)] + (t/is (path.impl/path-data? result)) + (t/is (= (into (vec content) sub) (vec result))))) diff --git a/common/test/common_tests/types/shape_decode_encode_test.cljc b/common/test/common_tests/types/shape_decode_encode_test.cljc index 0068633d32..8d24cff871 100644 --- a/common/test/common_tests/types/shape_decode_encode_test.cljc +++ b/common/test/common_tests/types/shape_decode_encode_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.shape-decode-encode-test (:require @@ -146,3 +146,24 @@ ;; (app.common.pprint/pprint shape-3) (= shape shape-3))) {:num 200}))) + +(t/deftest shape-generator-key-presence + "The generator must produce the keys the schema declares required, even when + nilable. This is a targeted check for the attributes added to + `schema:shape-generic-attrs` and `schema:nilable-geom-attrs`." + (let [shapes (sg/sample (sg/generator schema:shape) {:size 200}) + by-type (group-by :type shapes)] + ;; All shapes: rotation, flip-x, flip-y are base record fields, always + ;; present (possibly nil). + (doseq [shape shapes] + (t/is (contains? shape :rotation) "missing :rotation") + (t/is (contains? shape :flip-x) "missing :flip-x") + (t/is (contains? shape :flip-y) "missing :flip-y")) + ;; Bool and path: x/y/width/height are required-but-nilable in the + ;; schema. The generator must produce them (nil is a valid value). + (doseq [shape (concat (get by-type :bool []) + (get by-type :path []))] + (t/is (contains? shape :x) "bool/path missing :x") + (t/is (contains? shape :y) "bool/path missing :y") + (t/is (contains? shape :width) "bool/path missing :width") + (t/is (contains? shape :height) "bool/path missing :height")))) diff --git a/common/test/common_tests/types/shape_interactions_test.cljc b/common/test/common_tests/types/shape_interactions_test.cljc index da056ae136..6dbf7115c0 100644 --- a/common/test/common_tests/types/shape_interactions_test.cljc +++ b/common/test/common_tests/types/shape_interactions_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.shape-interactions-test (:require @@ -10,6 +10,7 @@ [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] [app.common.geom.shapes :as gsh] + [app.common.geom.shapes.bounds :as gsb] [app.common.math :as mth] [app.common.types.shape :as cts] [app.common.types.shape.interactions :as ctsi] @@ -1078,3 +1079,49 @@ [overlay-pos snap] (ctsi/calc-overlay-position frame-relative base-frame objects base-frame base-frame overlay-frame frame-offset)] (t/is (= (gpt/point 18 22) overlay-pos)) (t/is (= [:top :left] snap)))))) + +(t/deftest calc-overlay-position-ignores-filter-bounds + ;; Regression for #9048: the overlay position must be computed from the + ;; destination frame selrect (the visible frame box), not from its + ;; filter-inflated object bounds. Shadows, blur, strokes or overflowing + ;; children make get-object-bounds larger than the selrect, which used to + ;; shift centered/right/bottom overlays by half that extra padding (the + ;; overlay appeared offset, e.g. "a bit to the left"). + (let [base-frame (cts/setup-shape {:type :frame :width 100 :height 100}) + overlay-plain (cts/setup-shape {:type :frame :width 30 :height 20}) + ;; same selrect as overlay-plain, but with a drop shadow that widens + ;; and heightens its object bounds well beyond the selrect. + overlay-shadow (-> (cts/setup-shape {:type :frame :width 30 :height 20}) + (assoc :shadow [{:style :drop-shadow + :offset-x 0 :offset-y 0 + :spread 10 :blur 0 :hidden false}])) + objects {(:id base-frame) base-frame + (:id overlay-plain) overlay-plain + (:id overlay-shadow) overlay-shadow} + frame-offset (gpt/point 5 5) + interaction (-> ctsi/default-interaction + (ctsi/set-action-type :open-overlay) + (ctsi/set-position-relative-to (:id base-frame)))] + + ;; Precondition: the shadow really does inflate the object bounds, so the + ;; assertions below are meaningful (otherwise the test would be vacuous). + (t/is (> (:width (gsb/get-object-bounds objects overlay-shadow)) + (:width (:selrect overlay-shadow)))) + (t/is (> (:height (gsb/get-object-bounds objects overlay-shadow)) + (:height (:selrect overlay-shadow)))) + + ;; For every position type that depends on the overlay size, the computed + ;; position must be identical whether or not the destination frame has a + ;; bounds-inflating shadow. + (doseq [pos-type [:center :top-center :top-right :bottom-center :bottom-right]] + (let [i-plain (-> interaction + (ctsi/set-destination (:id overlay-plain)) + (ctsi/set-overlay-pos-type pos-type base-frame objects)) + i-shadow (-> interaction + (ctsi/set-destination (:id overlay-shadow)) + (ctsi/set-overlay-pos-type pos-type base-frame objects)) + [pos-plain snap-plain] (ctsi/calc-overlay-position i-plain base-frame objects base-frame base-frame overlay-plain frame-offset) + [pos-shadow snap-shadow] (ctsi/calc-overlay-position i-shadow base-frame objects base-frame base-frame overlay-shadow frame-offset)] + (t/testing (str "overlay position ignores filter bounds for " pos-type) + (t/is (= pos-plain pos-shadow)) + (t/is (= snap-plain snap-shadow))))))) diff --git a/common/test/common_tests/types/shape_layout_test.cljc b/common/test/common_tests/types/shape_layout_test.cljc index e655c66713..308dd758a6 100644 --- a/common/test/common_tests/types/shape_layout_test.cljc +++ b/common/test/common_tests/types/shape_layout_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.shape-layout-test (:require diff --git a/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc index b63a6db6e1..8165558bed 100644 --- a/common/test/common_tests/types/text_test.cljc +++ b/common/test/common_tests/types/text_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.text-test (:require @@ -78,6 +78,14 @@ (def content-changed-line-height (assoc-in content-base [:children 0 :children 0 :line-height] "1.5")) +;; Token/WASM may store full float precision; editor round-trips often +;; truncate (e.g. CSS / f32). These must compare as equal. +(def content-line-height-full-precision + (assoc-in content-base [:children 0 :children 0 :line-height] "1.3333333333333333")) + +(def content-line-height-truncated + (assoc-in content-base [:children 0 :children 0 :line-height] "1.33333")) + (def content-redundant-span-line-height (assoc-in content-base [:children 0 :children 0 :children 0 :line-height] "1.5")) @@ -208,6 +216,8 @@ ;; Other text-node-attr categories attrs-font-family (cttx/get-diff-attrs content-base content-changed-font-family) attrs-line-height (cttx/get-diff-attrs content-base content-changed-line-height) + attrs-line-height-precision (cttx/get-diff-attrs content-line-height-full-precision + content-line-height-truncated) attrs-span-line-height (cttx/get-diff-attrs content-base content-redundant-span-line-height) attrs-roundtrip-line-height (cttx/get-diff-attrs content-token-like-line-height content-after-editor-roundtrip) @@ -242,6 +252,7 @@ ;; Each text-node-attr category reports correct attr key (t/is (= #{:font-family} attrs-font-family)) (t/is (= #{:line-height} attrs-line-height)) + (t/is (= #{} attrs-line-height-precision)) (t/is (= #{} attrs-span-line-height)) (t/is (= #{} attrs-roundtrip-line-height)) (t/is (= #{} attrs-nil-typography-refs)) diff --git a/common/test/common_tests/types/token_test.cljc b/common/test/common_tests/types/token_test.cljc index 6f9106b6ad..3cded0dc4f 100644 --- a/common/test/common_tests/types/token_test.cljc +++ b/common/test/common_tests/types/token_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.token-test (:require diff --git a/common/test/common_tests/types/tokens_lib_test.cljc b/common/test/common_tests/types/tokens_lib_test.cljc index bb2d5cf204..3a37c82a51 100644 --- a/common/test/common_tests/types/tokens_lib_test.cljc +++ b/common/test/common_tests/types/tokens_lib_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.tokens-lib-test (:require diff --git a/common/test/common_tests/types/tokens_migrations_test.cljc b/common/test/common_tests/types/tokens_migrations_test.cljc index 04bbd9d7a1..779c24395f 100644 --- a/common/test/common_tests/types/tokens_migrations_test.cljc +++ b/common/test/common_tests/types/tokens_migrations_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.tokens-migrations-test (:require diff --git a/common/test/common_tests/types/tokens_status_test.cljc b/common/test/common_tests/types/tokens_status_test.cljc new file mode 100644 index 0000000000..d2ebb81dd3 --- /dev/null +++ b/common/test/common_tests/types/tokens_status_test.cljc @@ -0,0 +1,101 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns common-tests.types.tokens-status-test + (:require + #?(:clj [app.common.fressian :as fres]) + #?(:clj [clojure.data.json :as json]) + [app.common.transit :as tr] + [app.common.types.tokens-status :as ctos] + [app.common.uuid :as uuid] + [clojure.datafy :refer [datafy]] + [clojure.test :as t])) + +(t/deftest make-tokens-status + (let [theme-id (uuid/next) + set-id (uuid/next) + status (ctos/make-tokens-status :active-theme-ids #{theme-id} + :active-set-ids #{set-id})] + (t/is (ctos/tokens-status? status)) + (t/is (ctos/check-tokens-status status)) + (t/is (= 1 (count (ctos/get-active-theme-ids status)))) + (t/is (ctos/theme-active? status theme-id)) + (t/is (= 1 (count (ctos/get-active-set-ids status)))) + (t/is (ctos/set-active? status set-id)))) + +(t/deftest make-tokens-status-defaults + (let [status (ctos/make-tokens-status)] + (t/is (ctos/tokens-status? status)) + (t/is (ctos/check-tokens-status status)) + (t/is (= 0 (count (ctos/get-active-theme-ids status)))) + (t/is (= 0 (count (ctos/get-active-set-ids status)))))) + +(t/deftest make-invalid-tokens-status + (t/testing "non-set for active-themes" + (t/is (thrown-with-msg? #?(:cljs js/Error :clj Exception) + #"expected valid params for tokens-status" + (ctos/make-tokens-status :active-theme-ids [])))) + (t/testing "non-uuid in active-sets" + (t/is (thrown-with-msg? #?(:cljs js/Error :clj Exception) + #"expected valid params for tokens-status" + (ctos/make-tokens-status :active-set-ids #{"not-a-uuid"}))))) + +(t/deftest set-tokens-status + (let [theme1-id (uuid/next) + theme2-id (uuid/next) + theme3-id (uuid/next) + set1-id (uuid/next) + set2-id (uuid/next) + set3-id (uuid/next) + status (-> (ctos/make-tokens-status {:active-theme-ids #{theme3-id} + :active-set-ids #{set3-id}}) + (ctos/set-tokens-status #{theme1-id theme2-id} #{set1-id set2-id}))] + (t/is (= #{theme1-id theme2-id} (ctos/get-active-theme-ids status))) + (t/is (= #{set1-id set2-id} (ctos/get-active-set-ids status))))) + +(t/deftest datafy-tokens-status + (let [theme-id (uuid/next) + set-id (uuid/next) + status (ctos/make-tokens-status :active-theme-ids #{theme-id} + :active-set-ids #{set-id}) + result (datafy status)] + (t/is (map? result)) + (t/is (not (ctos/tokens-status? result))) + (t/is (= (:active-theme-ids result) #{theme-id})) + (t/is (= (:active-set-ids result) #{set-id})))) + +(t/deftest transit-serialization + (let [theme-id (uuid/next) + set-id (uuid/next) + status (ctos/make-tokens-status :active-theme-ids #{theme-id} + :active-set-ids #{set-id}) + encoded (tr/encode-str status) + status' (tr/decode-str encoded)] + (t/is (ctos/tokens-status? status')) + (t/is (= (datafy status') (datafy status))))) + +#?(:clj + (t/deftest fressian-serialization + (let [theme-id (uuid/next) + set-id (uuid/next) + status (ctos/make-tokens-status :active-theme-ids #{theme-id} + :active-set-ids #{set-id}) + encoded (fres/encode status) + status' (fres/decode encoded)] + (t/is (ctos/tokens-status? status')) + (t/is (= (datafy status') (datafy status)))))) + +#?(:clj + (t/deftest json-serialization + (let [theme-id (uuid/next) + set-id (uuid/next) + status (ctos/make-tokens-status :active-theme-ids #{theme-id} + :active-set-ids #{set-id}) + json-str (json/write-str status) + parsed (json/read-str json-str :key-fn keyword)] + (t/is (map? parsed)) + (t/is (= [(str theme-id)] (:active-theme-ids parsed))) + (t/is (= [(str set-id)] (:active-set-ids parsed)))))) diff --git a/common/test/common_tests/types/variant_test.cljc b/common/test/common_tests/types/variant_test.cljc index 6261798946..f477da5bd9 100644 --- a/common/test/common_tests/types/variant_test.cljc +++ b/common/test/common_tests/types/variant_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.variant-test (:require diff --git a/common/test/common_tests/undo_stack_test.cljc b/common/test/common_tests/undo_stack_test.cljc index f751506228..10b65ff66c 100644 --- a/common/test/common_tests/undo_stack_test.cljc +++ b/common/test/common_tests/undo_stack_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.undo-stack-test (:require diff --git a/common/test/common_tests/uuid_test.cljc b/common/test/common_tests/uuid_test.cljc index ec6f14ceee..d30a8e8a09 100644 --- a/common/test/common_tests/uuid_test.cljc +++ b/common/test/common_tests/uuid_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.uuid-test (:require diff --git a/common/test/common_tests/variant_test.cljc b/common/test/common_tests/variant_test.cljc index f097e112b1..d0a7099dc7 100644 --- a/common/test/common_tests/variant_test.cljc +++ b/common/test/common_tests/variant_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.variant-test (:require diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 4aff930e9e..fb9e54fb63 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -66,7 +66,7 @@ RUN set -ex; \ FROM base AS setup-node -ENV NODE_VERSION=v24.18.1 \ +ENV NODE_VERSION=v24.20.0 \ PATH=/opt/node/bin:$PATH RUN set -eux; \ @@ -100,7 +100,7 @@ RUN set -eux; \ FROM base AS setup-opencode -ENV OPENCODE_VERSION=1.18.11 +ENV OPENCODE_VERSION=1.18.25 RUN set -ex; \ ARCH="$(dpkg --print-architecture)"; \ diff --git a/docker/devenv/docker-compose.opencode.yml b/docker/devenv/docker-compose.opencode.yml new file mode 100644 index 0000000000..1af29e3749 --- /dev/null +++ b/docker/devenv/docker-compose.opencode.yml @@ -0,0 +1,10 @@ +# Optional compose overlay, included by manage.sh's instance-compose ONLY +# when PENPOT_OPENCODE_CONFIG_DIR is set (run-devenv --opencode-config-dir +# DIR). Bind-mounts a host directory over the container's opencode global +# config dir (~/.config/opencode) so personal agents/prompts/skills kept in +# a separate repo are available inside the devenv without committing them +# here. Without the flag this file is never referenced. +services: + main: + volumes: + - "${PENPOT_OPENCODE_CONFIG_DIR}:/home/penpot/.config/opencode:z" diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 7c0b1a14ff..46fbb0041d 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.18.1-debian13-dev +FROM dhi.io/node:24.20.0-debian13-dev LABEL maintainer="Penpot <docker@penpot.app>" ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 306c42467d..a134f15f27 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -25,6 +25,7 @@ COPY $BUNDLE_PATH /var/www/app/ COPY ./files/config.js /var/www/app/js/config.js COPY ./files/nginx.conf.template /tmp/nginx.conf.template COPY ./files/nginx-resolvers.conf.template /tmp/resolvers.conf.template +COPY ./files/nginx-admin-console-locations.conf.template /tmp/nginx-admin-console-locations.conf.template COPY ./files/nginx-mcp-locations.conf.template /tmp/nginx-mcp-locations.conf.template COPY ./files/nginx-security-headers.conf /etc/nginx/nginx-security-headers.conf COPY ./files/nginx-mime.types /etc/nginx/mime.types diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index 8d943c7e27..bad957b07c 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.18.1-debian13-dev AS build +FROM dhi.io/node:24.20.0-debian13-dev AS build LABEL maintainer="Penpot <docker@penpot.app>" ENV DEBIAN_FRONTEND=noninteractive diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor new file mode 100644 index 0000000000..3a1c15e9ae --- /dev/null +++ b/docker/images/Dockerfile.media-processor @@ -0,0 +1,86 @@ +FROM ubuntu:26.04 +LABEL maintainer="Penpot <docker@penpot.app>" + +ENV LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 \ + NODE_VERSION=v24.20.0 \ + DEBIAN_FRONTEND=noninteractive \ + PATH=/opt/node/bin:$PATH + +RUN set -ex; \ + useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \ + mkdir -p /etc/resolvconf/resolv.conf.d; \ + echo "nameserver 127.0.0.11" > /etc/resolvconf/resolv.conf.d/tail; \ + apt-get -qq update; \ + apt-get -qq dist-upgrade; \ + apt-get -qqy --no-install-recommends install \ + curl \ + tzdata \ + locales \ + ca-certificates \ + ; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/*; \ + echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen; \ + locale-gen; \ + find /usr/share/i18n/locales/ -type f ! -name "en_US" ! -name "POSIX" ! -name "C" -delete; + +RUN set -ex; \ + apt-get -qq update; \ + apt-get -qqy --no-install-recommends install \ + fontforge \ + woff-tools \ + woff2 \ + \ + libgomp1 \ + libheif1 \ + libjpeg-turbo8 \ + liblcms2-2 \ + libopenexr-3-1-30 \ + libopenjp2-7 \ + libpng16-16 \ + librsvg2-2 \ + libtiff6 \ + libwebp7 \ + libwebpdemux2 \ + libwebpmux3 \ + libxml2-16 \ + libzip5 \ + libzstd1 \ + ; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/*; + +RUN set -eux; \ + ARCH="$(dpkg --print-architecture)"; \ + case "${ARCH}" in \ + aarch64|arm64) \ + BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \ + ;; \ + amd64|x86_64) \ + BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \ + ;; \ + *) \ + echo "Unsupported arch: ${ARCH}"; \ + exit 1; \ + ;; \ + esac; \ + curl -LfsSo /tmp/nodejs.tar.gz ${BINARY_URL}; \ + mkdir -p /opt/node; \ + cd /opt/node; \ + tar -xf /tmp/nodejs.tar.gz --strip-components=1; \ + chown -R root /opt/node; \ + rm -rf /tmp/nodejs.tar.gz; \ + corepack enable; \ + mkdir -p /opt/penpot; \ + chown -R penpot:penpot /opt/penpot; + +ARG BUNDLE_PATH="./bundle-media-processor/" +COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/media-processor/ + +WORKDIR /opt/penpot/media-processor +USER penpot:penpot + +RUN ./setup + +CMD ["node", "dist/index.js"] diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index 59b326c76d..63e92d9916 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -38,7 +38,7 @@ x-body-size: &penpot-http-body-size ## Penpot SECRET KEY. It serves as a master key from which other keys for subsystems ## (eg http sessions, or invitations) are derived. ## -## We recommend to use a trully randomly generated +## We recommend to use a truly randomly generated ## 512 bits base64 encoded string here. You can generate one with: ## ## python3 -c "import secrets; print(secrets.token_urlsafe(64))" @@ -78,7 +78,7 @@ services: # - "443:443" penpot-frontend: - image: "penpotapp/frontend:${PENPOT_VERSION:-2.16}" + image: "penpotapp/frontend:${PENPOT_VERSION:-2.17}" restart: always ports: - 9001:8080 @@ -111,7 +111,7 @@ services: # PENPOT_DISABLE_IPV6_LISTEN: "true" penpot-backend: - image: "penpotapp/backend:${PENPOT_VERSION:-2.16}" + image: "penpotapp/backend:${PENPOT_VERSION:-2.17}" restart: always volumes: @@ -180,13 +180,13 @@ services: PENPOT_SMTP_SSL: "false" penpot-mcp: - image: "penpotapp/mcp:${PENPOT_VERSION:-2.16}" + image: "penpotapp/mcp:${PENPOT_VERSION:-2.17}" restart: always networks: - penpot penpot-exporter: - image: "penpotapp/exporter:${PENPOT_VERSION:-2.16}" + image: "penpotapp/exporter:${PENPOT_VERSION:-2.17}" restart: always depends_on: @@ -197,14 +197,24 @@ services: - penpot environment: - << : [*penpot-secret-key, *penpot-public-uri] + << : [*penpot-flags, *penpot-secret-key, *penpot-public-uri] # Don't touch it; this uses an internal docker network to # communicate with the frontend. PENPOT_INTERNAL_URI: http://penpot-frontend:8080 - ## Valkey (or previously Redis) is used for the websockets notifications. + ## Valkey (or previously Redis) is used for the websockets notifications + ## and for storing the state export jobs PENPOT_REDIS_URI: redis://penpot-valkey/0 + # PENPOT_EXPORTER_MAX_CONCURRENT_JOBS: 4 + # PENPOT_EXPORTER_MAX_JOBS_PER_PROFILE: 2 + # PENPOT_EXPORTER_QUEUE_MAX: 64 + # PENPOT_EXPORTER_JOB_TTL: 3600 + # PENPOT_WASM_WORKER_POOL_MAX: 2 + # PENPOT_WASM_WORKER_POOL_MIN: 1 + # PENPOT_WASM_WORKER_IDLE_TIMEOUT: 300 + # PENPOT_WASM_WORKER_IMAGE_CACHE_SIZE: 134217728 + penpot-postgres: image: "postgres:15" restart: always diff --git a/docker/images/files/nginx-admin-console-locations.conf.template b/docker/images/files/nginx-admin-console-locations.conf.template new file mode 100644 index 0000000000..fead3783c9 --- /dev/null +++ b/docker/images/files/nginx-admin-console-locations.conf.template @@ -0,0 +1,7 @@ +location /admin-console { + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $http_cf_connecting_ip; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass $PENPOT_ADMIN_CONSOLE_URI$request_uri; +} diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index fe2fbc9abf..47a8a5544b 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -53,15 +53,22 @@ update_oidc_name /var/www/app/js/config.js export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060} export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061} -export PENPOT_ADMIN_CONSOLE_URI=${PENPOT_ADMIN_CONSOLE_URI:-http://penpot-nitrate:3000} export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB export PENPOT_IPV6_LISTEN_DIRECTIVE=${PENPOT_IPV6_LISTEN_DIRECTIVE:-"listen [::]:8080 default_server reuseport backlog=16384;"} if is_truthy "${PENPOT_DISABLE_IPV6_LISTEN:-}"; then export PENPOT_IPV6_LISTEN_DIRECTIVE="" fi -envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_ADMIN_CONSOLE_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \ +envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \ < /tmp/nginx.conf.template > /etc/nginx/nginx.conf +if [[ $PENPOT_FLAGS == *"enable-admin-console"* ]]; then + export PENPOT_ADMIN_CONSOLE_URI=${PENPOT_ADMIN_CONSOLE_URI:-http://penpot-admin-console:3000} + envsubst "\$PENPOT_ADMIN_CONSOLE_URI" \ + < /tmp/nginx-admin-console-locations.conf.template > /etc/nginx/overrides/server.d/admin-console-locations.conf +else + rm -f /etc/nginx/overrides/server.d/admin-console-locations.conf +fi + if [[ $PENPOT_FLAGS == *"enable-mcp"* ]]; then export PENPOT_MCP_URI=${PENPOT_MCP_URI:-http://penpot-mcp:4401} export PENPOT_MCP_URI_WS=${PENPOT_MCP_URI_WS:-http://penpot-mcp:4402} diff --git a/docker/images/files/nginx-mcp-locations.conf.template b/docker/images/files/nginx-mcp-locations.conf.template index ab4df0acbb..6ff6fda592 100644 --- a/docker/images/files/nginx-mcp-locations.conf.template +++ b/docker/images/files/nginx-mcp-locations.conf.template @@ -1,16 +1,19 @@ location /mcp/ws { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; - proxy_pass $PENPOT_MCP_URI_WS; + set $mcp_ws_backend $PENPOT_MCP_URI_WS; + proxy_pass $mcp_ws_backend; proxy_http_version 1.1; } location /mcp/stream { - proxy_pass $PENPOT_MCP_URI/mcp; + set $mcp_stream_backend $PENPOT_MCP_URI/mcp$is_args$args; + proxy_pass $mcp_stream_backend; proxy_http_version 1.1; } location /mcp/sse { - proxy_pass $PENPOT_MCP_URI/sse; + set $mcp_sse_backend $PENPOT_MCP_URI/sse$is_args$args; + proxy_pass $mcp_sse_backend; proxy_http_version 1.1; } diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index 8524f4fb3d..75a385f9d6 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -111,6 +111,7 @@ http { } location /assets { + proxy_set_header Host $proxy_host; proxy_pass $PENPOT_BACKEND_URI/assets; recursive_error_pages on; proxy_intercept_errors on; @@ -127,10 +128,12 @@ http { } location /api/export { + proxy_set_header Host $proxy_host; proxy_pass $PENPOT_EXPORTER_URI; } location /api { + proxy_set_header Host $proxy_host; proxy_pass $PENPOT_BACKEND_URI/api; proxy_buffering off; } @@ -142,28 +145,32 @@ http { location /readyz { access_log off; + proxy_set_header Host $proxy_host; proxy_pass $PENPOT_BACKEND_URI$request_uri; } location /ws/notifications { + proxy_set_header Host $proxy_host; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_pass $PENPOT_BACKEND_URI/ws/notifications; } - location /admin-console { - proxy_http_version 1.1; - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $http_cf_connecting_ip; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_pass $PENPOT_ADMIN_CONSOLE_URI$request_uri; - } - include /etc/nginx/overrides/server.d/*.conf; location / { include /etc/nginx/overrides/location.d/*.conf; + # Regenerated from the environment on every container start + # (see nginx-entrypoint.sh) while its URL is only versioned by + # the build, so a flags only restart leaves the URL untouched. + # Caching it like a build asset would keep returning users on + # the previous PENPOT_FLAGS for up to a week. + location = /js/config.js { + include /etc/nginx/nginx-security-headers.conf; + add_header Cache-Control "no-store, no-cache, max-age=0" always; + } + location ~* \.(js|css|jpg|png|svg|gif|ttf|woff|woff2|wasm|map)$ { include /etc/nginx/nginx-security-headers.conf; add_header Cache-Control "public, max-age=604800" always; # 7 days diff --git a/docs/img/teams/team-selector-projects.webp b/docs/img/teams/team-selector-projects.webp index e1696680ec..c7ba762604 100644 Binary files a/docs/img/teams/team-selector-projects.webp and b/docs/img/teams/team-selector-projects.webp differ diff --git a/docs/img/teams/team-selector.webp b/docs/img/teams/team-selector.webp index c29357d264..e7af5e1523 100644 Binary files a/docs/img/teams/team-selector.webp and b/docs/img/teams/team-selector.webp differ diff --git a/docs/mcp/index.md b/docs/mcp/index.md index 8509bb7574..3f11995d2b 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -26,7 +26,7 @@ Penpot MCP enables **multi-directional workflows** between design and code. Beca title="Quick demo: Penpot MCP server in action" width="100%" height="480" - src="https://www.youtube.com/embed/CfvcgMQEmLk?rel=0" + src="https://www.youtube.com/embed/7V01SKVG6PQ?rel=0" loading="lazy" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" diff --git a/docs/package.json b/docs/package.json index c5286a7d57..7d498ee015 100644 --- a/docs/package.json +++ b/docs/package.json @@ -29,15 +29,15 @@ "@11ty/eleventy-plugin-rss": "^3.0.0", "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2", "@tigersway/eleventy-plugin-ancestry": "^1.0.3", - "@types/markdown-it": "14.1.2", + "@types/markdown-it": "14.2.0", "elasticlunr": "^0.9.5", "eleventy-plugin-metagen": "^1.8.4", "eleventy-plugin-nesting-toc": "^1.3.0", "eleventy-plugin-youtube-embed": "^1.13.2", "luxon": "^3.7.2", - "markdown-it": "^14.3.0", + "markdown-it": "^15.0.0", "markdown-it-anchor": "^9.2.1", "markdown-it-plantuml": "^1.4.1" }, - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/docs/plugins/create-a-plugin.md b/docs/plugins/create-a-plugin.md index 9a25d101b9..0a8f14a8fe 100644 --- a/docs/plugins/create-a-plugin.md +++ b/docs/plugins/create-a-plugin.md @@ -116,6 +116,10 @@ Your plugin can capture incoming messages from Penpot using the <code class="lan ```js window.addEventListener("message", (event) => { + // Validate the source to ensure messages come from the parent (Penpot) + if (event.source !== window.parent) { + return; + } // Handle the incoming message console.log(event.data); }); @@ -129,11 +133,11 @@ This setup allows for two-way communication between Penpot and your plugin. Penp ```js // Sending a message back to Penpot from your plugin -parent.postMessage(responseMessage, targetOrigin); +parent.postMessage(responseMessage, "*"); ``` -<code class="language-js">responseMessage</code> is the data you want to send back to Penpot. --<code class="language-js">targetOrigin</code> should be the origin of the Penpot application to ensure messages are only sent to the intended recipient. You can use<code class="language-js">'*'</code> to allow all. +- Using<code class="language-js">'*'</code> as the target origin is acceptable here because the message content is controlled by your plugin (the sender), not by untrusted input. If you know the exact Penpot origin, you can use it instead for stricter security. ### Summary diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 2d324a4b2a..5938203566 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- lockfileVersion: '9.0' settings: @@ -24,8 +125,8 @@ importers: specifier: ^1.0.3 version: 1.0.3(@11ty/eleventy@3.1.6) '@types/markdown-it': - specifier: 14.1.2 - version: 14.1.2 + specifier: 14.2.0 + version: 14.2.0 elasticlunr: specifier: ^0.9.5 version: 0.9.5 @@ -42,11 +143,11 @@ importers: specifier: ^3.7.2 version: 3.7.2 markdown-it: - specifier: ^14.3.0 - version: 14.3.0 + specifier: ^15.0.0 + version: 15.0.0 markdown-it-anchor: specifier: ^9.2.1 - version: 9.2.1(@types/markdown-it@14.1.2)(markdown-it@14.3.0) + version: 9.2.1(@types/markdown-it@14.2.0)(markdown-it@15.0.0) markdown-it-plantuml: specifier: ^1.4.1 version: 1.4.1 @@ -64,8 +165,8 @@ packages: engines: {node: '>=18'} hasBin: true - '@11ty/eleventy-fetch@5.1.2': - resolution: {integrity: sha512-YxDARdR3S9UT4gOGRWgGNyokYT9jkCAjJge3OVKFdNMv1cyvWg1NHCvj9NVvK9XHenCLFy3cwvM/YYpZZTZopw==} + '@11ty/eleventy-fetch@5.1.3': + resolution: {integrity: sha512-4HS6QB/mVWTVlE6kjCKPkjkC1Z0YOqizJaHR05zK+E7a2b4EUC3T+wLktIy7wEl76ZoarkY45EKLmTw1XuR8fQ==} engines: {node: '>=18'} '@11ty/eleventy-navigation@1.0.5': @@ -102,8 +203,8 @@ packages: resolution: {integrity: sha512-oI7m8pa7/IAU/3lqRU9vjBbs20iKFo7x+1K9kT3aVira6scc1X9MjBdgLCHzLJeJ7iB6wydioA+kr9/qPnvmlQ==} engines: {node: '>=18'} - '@rgrove/parse-xml@4.2.0': - resolution: {integrity: sha512-UuBOt7BOsKVOkFXRe4Ypd/lADuNIfqJXv8GvHqtXaTYXPPKkj2nS2zPllVsrtRjcomDhIJVBnZwfmlI222WH8g==} + '@rgrove/parse-xml@4.2.3': + resolution: {integrity: sha512-Jhlb+0zYez1T1yXUQs3F1qAtFuJljBVNdy9TKmLDauAXkxsOXopKYOyQ5Wm6SvP3fycav0GviX4Y15WWhGetMw==} engines: {node: '>=14.0.0'} '@sindresorhus/slugify@2.2.1': @@ -122,8 +223,8 @@ packages: '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} - '@types/markdown-it@14.1.2': - resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + '@types/markdown-it@14.2.0': + resolution: {integrity: sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==} '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} @@ -135,8 +236,8 @@ packages: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true @@ -150,6 +251,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + argparse@3.0.1: + resolution: {integrity: sha512-nM4mHF/KM1v59ZNKX7zfusQz5wUAxR511YG8Vo6TyiV4aqhu++rbJW4v04xsWhpSsHFj66flT8P7znVpyO20xQ==} + asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} @@ -162,8 +266,8 @@ packages: bcp-47-normalize@2.3.0: resolution: {integrity: sha512-8I/wfzqQvttUFz7HVJgIZ7+dj3vUaIyIxYXaTRP1YWoSDfzt6TUmxaKZeuXR62qBmYr+nvuWINFRl6pZ5DlN4Q==} - bcp-47@2.1.0: - resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==} + bcp-47@2.1.1: + resolution: {integrity: sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw==} binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} @@ -172,8 +276,8 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@1.1.15: - resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -301,6 +405,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + errno@1.0.0: resolution: {integrity: sha512-3zV5mFS1E8/1bPxt/B0xxzI1snsg3uSCIh6Zo1qKg6iMw93hzPANk9oBFzSFBFrwuVoQuE3rLoouAUfwOAj1wQ==} hasBin: true @@ -356,8 +464,8 @@ packages: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} @@ -432,16 +540,16 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - iso-639-1@3.1.5: - resolution: {integrity: sha512-gXkz5+KN7HrG0Q5UGqSMO2qB9AsbEeyLP54kF1YrMsIxmu+g4BdB7rflReZTSTZGpfj8wywu6pfPBCylPIzGQA==} + iso-639-1@3.1.6: + resolution: {integrity: sha512-ZFar/L4ngX7wZh2QX+Fiftmuf0igWJsrJtfizrovWifF1gAWkfmRa5Z1m0LQZbm0hKCHRDYhLRSLFrSqNe4EJA==} engines: {node: '>=6.0'} - js-yaml@3.15.0: - resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + js-yaml@3.15.1: + resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} hasBin: true - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true junk@3.1.0: @@ -459,8 +567,11 @@ packages: linkify-it@5.0.2: resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} - liquidjs@10.27.0: - resolution: {integrity: sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w==} + linkify-it@6.1.0: + resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==} + + liquidjs@10.29.0: + resolution: {integrity: sha512-pCVOhs6FLAR8su3ItJ07diN26t6W5dHQRnmTMy8HPyTFuv1+oSCVJIGp5pGjfQyOZfh50KswvKtMTp6p4JEIdw==} engines: {node: '>=16'} hasBin: true @@ -487,6 +598,10 @@ packages: resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} hasBin: true + markdown-it@15.0.0: + resolution: {integrity: sha512-Lf8ajvVNdRpzSNB4VegxNy7gjs8gU35l4b4+ET49LrQC5PKYwLZ72u60LeJ9gv3qiaesuYjJWCyVeQmv/QWKQw==} + hasBin: true + mdurl@2.1.0: resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} @@ -584,8 +699,8 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} please-upgrade-node@3.2.0: @@ -620,8 +735,8 @@ packages: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} readdirp@3.6.0: @@ -638,8 +753,8 @@ packages: semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} - semver@7.8.4: - resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -692,8 +807,11 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + uc.micro@3.0.0: + resolution: {integrity: sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==} + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} unpipe@1.0.0: @@ -712,8 +830,8 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -729,7 +847,7 @@ snapshots: '@11ty/dependency-tree-esm@2.0.4': dependencies: '@11ty/eleventy-utils': 2.0.7 - acorn: 8.17.0 + acorn: 8.18.0 dependency-graph: 1.0.0 normalize-path: 3.0.0 @@ -750,18 +868,18 @@ snapshots: send: 1.2.1 ssri: 11.0.0 urlpattern-polyfill: 10.1.0 - ws: 8.21.0 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@11ty/eleventy-fetch@5.1.2': + '@11ty/eleventy-fetch@5.1.3': dependencies: '@11ty/eleventy-utils': 2.0.7 - '@rgrove/parse-xml': 4.2.0 + '@rgrove/parse-xml': 4.2.3 debug: 4.4.3 - flatted: 3.4.2 + flatted: 3.4.4 p-queue: 6.6.2 transitivePeerDependencies: - supports-color @@ -812,21 +930,21 @@ snapshots: entities: 6.0.1 filesize: 10.1.6 gray-matter: 4.0.3 - iso-639-1: 3.1.5 - js-yaml: 4.2.0 + iso-639-1: 3.1.6 + js-yaml: 4.3.1 kleur: 4.1.5 - liquidjs: 10.27.0 + liquidjs: 10.29.0 luxon: 3.7.2 markdown-it: 14.3.0 minimist: 1.2.8 moo: 0.5.2 node-retrieve-globals: 6.0.1 nunjucks: 3.2.4(chokidar@3.6.0) - picomatch: 4.0.4 + picomatch: 4.0.7 please-upgrade-node: 3.2.0 posthtml: 0.16.7 posthtml-match-helper: 2.0.3(posthtml@0.16.7) - semver: 7.8.4 + semver: 7.8.5 slugify: 1.6.9 tinyglobby: 0.2.17 transitivePeerDependencies: @@ -850,7 +968,7 @@ snapshots: minimatch: 3.1.5 slash: 3.0.0 - '@rgrove/parse-xml@4.2.0': {} + '@rgrove/parse-xml@4.2.3': {} '@sindresorhus/slugify@2.2.1': dependencies: @@ -867,7 +985,7 @@ snapshots: '@types/linkify-it@5.0.0': {} - '@types/markdown-it@14.1.2': + '@types/markdown-it@14.2.0': dependencies: '@types/linkify-it': 5.0.0 '@types/mdurl': 2.0.0 @@ -878,9 +996,9 @@ snapshots: acorn-walk@8.3.5: dependencies: - acorn: 8.17.0 + acorn: 8.18.0 - acorn@8.17.0: {} + acorn@8.18.0: {} anymatch@3.1.3: dependencies: @@ -893,6 +1011,8 @@ snapshots: argparse@2.0.1: {} + argparse@3.0.1: {} + asap@2.0.6: {} balanced-match@1.0.2: {} @@ -901,10 +1021,10 @@ snapshots: bcp-47-normalize@2.3.0: dependencies: - bcp-47: 2.1.0 + bcp-47: 2.1.1 bcp-47-match: 2.0.3 - bcp-47@2.1.0: + bcp-47@2.1.1: dependencies: is-alphabetical: 2.0.1 is-alphanumerical: 2.0.1 @@ -914,7 +1034,7 @@ snapshots: boolbase@1.0.0: {} - brace-expansion@1.1.15: + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 @@ -943,7 +1063,7 @@ snapshots: parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 parse5-parser-stream: 7.1.2 - undici: 7.28.0 + undici: 7.29.0 whatwg-mimetype: 4.0.0 chokidar@3.6.0: @@ -1041,7 +1161,7 @@ snapshots: eleventy-plugin-youtube-embed@1.13.2: dependencies: - '@11ty/eleventy-fetch': 5.1.2 + '@11ty/eleventy-fetch': 5.1.3 deepmerge: 4.3.1 lite-youtube-embed: 0.3.4 string-replace-async: 3.0.2 @@ -1065,6 +1185,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + errno@1.0.0: dependencies: prr: 1.0.1 @@ -1075,7 +1197,7 @@ snapshots: esm-import-transformer@3.0.5: dependencies: - acorn: 8.17.0 + acorn: 8.18.0 esprima@4.0.1: {} @@ -1089,9 +1211,9 @@ snapshots: dependencies: is-extendable: 0.1.1 - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.7 filesize@10.1.6: {} @@ -1111,7 +1233,7 @@ snapshots: transitivePeerDependencies: - supports-color - flatted@3.4.2: {} + flatted@3.4.4: {} fresh@2.0.0: {} @@ -1126,7 +1248,7 @@ snapshots: gray-matter@4.0.3: dependencies: - js-yaml: 3.15.0 + js-yaml: 3.15.1 kind-of: 6.0.3 section-matter: 1.0.0 strip-bom-string: 1.0.0 @@ -1186,14 +1308,14 @@ snapshots: is-number@7.0.0: {} - iso-639-1@3.1.5: {} + iso-639-1@3.1.6: {} - js-yaml@3.15.0: + js-yaml@3.15.1: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.2.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -1207,7 +1329,11 @@ snapshots: dependencies: uc.micro: 2.1.0 - liquidjs@10.27.0: + linkify-it@6.1.0: + dependencies: + uc.micro: 3.0.0 + + liquidjs@10.29.0: dependencies: commander: 10.0.1 @@ -1217,10 +1343,10 @@ snapshots: luxon@3.7.2: {} - markdown-it-anchor@9.2.1(@types/markdown-it@14.1.2)(markdown-it@14.3.0): + markdown-it-anchor@9.2.1(@types/markdown-it@14.2.0)(markdown-it@15.0.0): dependencies: - '@types/markdown-it': 14.1.2 - markdown-it: 14.3.0 + '@types/markdown-it': 14.2.0 + markdown-it: 15.0.0 markdown-it-plantuml@1.4.1: {} @@ -1233,6 +1359,15 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + markdown-it@15.0.0: + dependencies: + argparse: 3.0.1 + entities: 8.0.0 + linkify-it: 6.1.0 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 3.0.0 + mdurl@2.1.0: {} meta-generator@0.1.5: @@ -1249,7 +1384,7 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 1.1.15 + brace-expansion: 1.1.18 minimist@1.2.8: {} @@ -1265,7 +1400,7 @@ snapshots: node-retrieve-globals@6.0.1: dependencies: - acorn: 8.17.0 + acorn: 8.18.0 acorn-walk: 8.3.5 esm-import-transformer: 3.0.5 @@ -1317,7 +1452,7 @@ snapshots: picomatch@2.3.2: {} - picomatch@4.0.4: {} + picomatch@4.0.7: {} please-upgrade-node@3.2.0: dependencies: @@ -1346,7 +1481,7 @@ snapshots: punycode.js@2.3.1: {} - range-parser@1.2.1: {} + range-parser@1.3.0: {} readdirp@3.6.0: dependencies: @@ -1361,7 +1496,7 @@ snapshots: semver-compare@1.0.0: {} - semver@7.8.4: {} + semver@7.8.5: {} send@1.2.1: dependencies: @@ -1374,7 +1509,7 @@ snapshots: mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 - range-parser: 1.2.1 + range-parser: 1.3.0 statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -1399,8 +1534,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 to-regex-range@5.0.1: dependencies: @@ -1410,7 +1545,9 @@ snapshots: uc.micro@2.1.0: {} - undici@7.28.0: {} + uc.micro@3.0.0: {} + + undici@7.29.0: {} unpipe@1.0.0: {} @@ -1422,4 +1559,4 @@ snapshots: whatwg-mimetype@4.0.0: {} - ws@8.21.0: {} + ws@8.21.3: {} diff --git a/docs/pnpm-workspace.yaml b/docs/pnpm-workspace.yaml index 79b7d49256..bd2aa47620 100644 --- a/docs/pnpm-workspace.yaml +++ b/docs/pnpm-workspace.yaml @@ -1,3 +1,5 @@ minimumReleaseAgeExclude: - - undici@7.28.0 - - js-yaml@3.15.0 + - undici@7.28.0 || 7.29.0 + - js-yaml@3.15.0 || 4.3.0 + - brace-expansion@1.1.16 || 1.1.17 || 1.1.18 + - liquidjs@10.27.1 diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index 49d5a23e8d..1541f1d3b8 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -438,7 +438,7 @@ with this flag enabled, the Penpot configuration will disable as well the librar The mechanisms for installing Penpot in HA depend largely on how each infrastructure is managed. In this section, we mention the key factors to consider when replicating a Penpot installation: -The components that can be replicated are the `frontend`, the `backend`, and the `exporter`. +The components that can be replicated are the `frontend`, the `backend`, the `exporter` and the `mcp`. Replication management depends on the infrastructure, whether it's a load balancer or a Kubernetes deployment with HPA. In a high-availability (HA) scenario, managing the state outside of replicas is crucial. This affects the following components: @@ -447,12 +447,6 @@ In a high-availability (HA) scenario, managing the state outside of replicas is - Valkey: Penpot only needs one Valkey instance to function correctly. Due to the nature of the data it manages, replication isn't even essential. - User media storage: This should not be configured with local storage but rather with centralized storage, such as Kubernetes PVC or S3. - -__Since version 2.15.0__ - -Starting with version 2.15, we have introduced the MCP server. Due to architectural constraints, using the MCP server requires running only a single instance of Penpot. -If the MCP server is not installed, then Penpot can scale normally and multiple application instances may be deployed without restrictions. - ## Backend This section enumerates the backend only configuration variables. @@ -588,7 +582,7 @@ PENPOT_FLAGS: [...] enable-auto-file-snapshot # Enable automatic v # Backend PENPOT_AUTO_FILE_SNAPSHOT_EVERY: 5 # How many save operations trigger the auto-save-version? -PENPOT_AUTO_FILE_SNAPSHOT_TIIMEOUT: "1h" # How often is an automatic save forced even if the `every` trigger is not met? +PENPOT_AUTO_FILE_SNAPSHOT_TIMEOUT: "1h" # How often is an automatic save forced even if the `every` trigger is not met? ``` Setting custom values for auto-file-snapshot does not change the behaviour for manual versions. diff --git a/docs/technical-guide/developer/agentic-devenv.md b/docs/technical-guide/developer/agentic-devenv.md index 55b03c1492..932f555d6f 100644 --- a/docs/technical-guide/developer/agentic-devenv.md +++ b/docs/technical-guide/developer/agentic-devenv.md @@ -148,10 +148,15 @@ automatically, so regular users never run this. ```bash ./manage.sh run-devenv --agentic \ [--ws N] [--sync] [--serena-context CTX] \ + [--opencode-config-dir DIR] \ [--git-user-name NAME] [--git-user-email EMAIL] ``` Brings one agentic instance up. Errors out if the target is already running. +`--opencode-config-dir DIR` bind-mounts DIR over the container's +`~/.config/opencode` so personal agents/prompts/skills kept in a separate +repository are available to the coding agent; see the +[Dev environment guide](./devenv.md#personal-opencode-config-inside-the-container). `--ws N` (N ≥ 1) brings that workspace up independently — workspaces can be started and stopped in any order. Per-instance ports diff --git a/docs/technical-guide/developer/devenv.md b/docs/technical-guide/developer/devenv.md index 922ed24f1d..b5496abe2f 100644 --- a/docs/technical-guide/developer/devenv.md +++ b/docs/technical-guide/developer/devenv.md @@ -139,6 +139,27 @@ until you set an identity. The values are applied every time `run-devenv` brings an instance up (idempotent), so re-running with different flags is the way to change the in-container identity. +### Personal opencode config inside the container + +`run-devenv --opencode-config-dir DIR` bind-mounts a host directory over the +container's `~/.config/opencode` (opencode's global config dir). This is how +you keep personal agents, prompts, and skills in a separate repository and +use them inside the devenv without committing them here or leaving untracked +files in the repo: + +```bash +./manage.sh run-devenv --agentic --opencode-config-dir ../penpot-opencode +``` + +The path must be an existing directory; `~` is expanded and the value is +resolved to an absolute path automatically. The mount is applied at container +creation, so changing it requires stopping and re-running `run-devenv` for +that instance, and it applies only to instances brought up with the flag — +other workspaces mount nothing. The directory is shared read-write with the +container (same UID mapping as the source tree). Opencode's own state +(sessions, `auth.json`) lives in `~/.local/share/opencode`, which stays in +the container's data volume regardless of this flag. + ### Shared state and workers All instances share one Penpot database and one MinIO bucket; users, teams, @@ -338,7 +359,7 @@ cd mcp pnpm run bootstrap:multi-user ``` -This will start the MCP server and the multi-user plugin that will be loaded automaticaly by Penpot. +This will start the MCP server and the multi-user plugin that will be loaded automatically by Penpot. There is a NGINX proxy that makes a proxy-pass from outside the docker container so you don't need to remember the ports it's using. @@ -419,16 +440,28 @@ After creating or modifying this file, **reload the browser** (no need to restar ### Backend flags via PENPOT_FLAGS Backend feature flags are controlled through the `PENPOT_FLAGS` environment -variable using the same `enable-<flag>` / `disable-<flag>` format. You can set -this in the `docker/devenv/docker-compose.yaml` file under the `main` service -`environment` section: +variable using the same `enable-<flag>` / `disable-<flag>` format. The devenv +sets its own list in `backend/scripts/_env`. -```yaml -environment: - - PENPOT_FLAGS=enable-access-tokens enable-mcp +To change that list for your checkout, create `backend/scripts/_env.local`. +`backend/scripts/start-dev` sources it immediately after `_env`, and the file +is gitignored, so your override never appears in `git status`: + +```bash +export PENPOT_FLAGS="$PENPOT_FLAGS enable-access-tokens enable-mcp" ``` -This requires **restarting the backend** to take effect. +Flags are applied left to right and the last entry wins, so appending to +`$PENPOT_FLAGS` both adds flags and switches off ones that `_env` enables: +`disable-demo-users` at the end turns off the demo users that `_env` enables +earlier. + +Setting `PENPOT_FLAGS` in the container environment does not work for this, +because `_env` expands the inherited value *before* its own list. Any flag it +sets afterwards wins over yours. + +This requires **restarting the backend** to take effect: stop the process in +the `backend` tmux window and run `./scripts/start-dev` again. > **Note**: Some features (e.g., access tokens, webhooks) need both frontend and > backend flags enabled to work end-to-end. The frontend flag enables the UI, while diff --git a/docs/user-guide/account-teams/projects-files.njk b/docs/user-guide/account-teams/projects-files.njk index 9345cd4f45..02f1afd38c 100644 --- a/docs/user-guide/account-teams/projects-files.njk +++ b/docs/user-guide/account-teams/projects-files.njk @@ -10,7 +10,7 @@ desc: Learn how to organize your work in Penpot. Create, manage and organize pro <h2 id="projects-management">Projects</h2> <p>Projects are containers that help you organize and group related design files together. Think of them as folders in a file system. You can create as many projects as you need to organize your work by client, product, feature, or any other structure that fits your workflow.</p> -<p>If you're working with others, projects should be created inside a team so that team members can collaborate on the files within them. Projects created in your personal space ("Your Penpot") remain private to you.</p> +<p>If you're working with others, projects should be created inside a team so that team members can collaborate on the files within them. Projects created in your personal space ("Personal Projects") remain private to you.</p> <figure> <img src="/img/files-projects/01-projects.webp" alt="Projects view in dashboard" /> </figure> diff --git a/docs/user-guide/account-teams/teams.njk b/docs/user-guide/account-teams/teams.njk index 67fbc78187..550cf81390 100644 --- a/docs/user-guide/account-teams/teams.njk +++ b/docs/user-guide/account-teams/teams.njk @@ -16,7 +16,7 @@ member is allowed to do depends on their permissions.</p> <h3>Select team</h3> <p>At the top left of the dashboard you can find the team selector.</p> -<p>"Your Penpot" is the name of your personal space at Penpot. It is like any other team but in which no members can be invited so that you will always have your own private dashboard. Create or join other teams to collaborate with other Penpot users.</p> +<p>"Personal Projects" is the name of your personal space at Penpot. It is like any other team but in which no members can be invited so that you will always have your own private dashboard. Create or join other teams to collaborate with other Penpot users.</p> <figure><img src="/img/teams/team-selector.webp" alt="Teams selector" /></figure> <h3>Create teams</h3> diff --git a/docs/user-guide/first-steps/index.njk b/docs/user-guide/first-steps/index.njk index c26777b24f..e707f6d46e 100644 --- a/docs/user-guide/first-steps/index.njk +++ b/docs/user-guide/first-steps/index.njk @@ -31,4 +31,10 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial <p>Useful resources to better understand Penpot</p> </a> </li> + <li> + <a href="/user-guide/first-steps/migration-guide"> + <h2>Migration Guide →</h2> + <p>Move a design system from Figma to Penpot</p> + </a> + </li> </ul> diff --git a/docs/user-guide/first-steps/migration-guide.njk b/docs/user-guide/first-steps/migration-guide.njk new file mode 100644 index 0000000000..cd8600ff68 --- /dev/null +++ b/docs/user-guide/first-steps/migration-guide.njk @@ -0,0 +1,31 @@ +--- +title: Migration Guide +order: 6 +desc: Move a design system from Figma to Penpot. Read a short summary of the enterprise migration guide and open the full PDF. +--- + +<h1 id="migration-guide">Migration Guide</h1> + +<p class="main-paragraph">If you are moving a design system to Penpot, especially from Figma, start with the enterprise migration guide. It covers file and library migration, tokens, validation, dual-tool workflows, and how different roles can run a pilot.</p> + +<div class="advice"> + <p><strong>Open the full guide (PDF)</strong></p> + <p><a href="https://nextcloud.kaleidos.net/index.php/s/mKordyz62QF3PQ4?dir=/&editing=false&openfile=true" target="_blank" rel="noopener"><strong>The Enterprise Guide to Migrating Design Systems from Figma to Penpot</strong></a></p> +</div> + +<h2 id="what-the-guide-covers">What the guide covers</h2> +<p>The document is written for teams that need to move more than a few mockups: libraries, tokens, variants, and the workflows around them. It focuses on Figma, but the same audit, pilot, and validation steps apply if you are coming from another tool.</p> + +<ul> + <li><strong>Before you export:</strong> audit critical files, component chains, token usage, and plugins that will not come along. Split oversized files and clean unused libraries while you are still in Figma.</li> + <li><strong>Static assets:</strong> export SVG, PNG, or JPG from Figma and place them in Penpot.</li> + <li><strong>Complex files and libraries:</strong> use the Penpot Exporter plugin for Figma (design files, slides, components, variants, auto layout, styles, variables, and libraries). Expect some layout cleanup, Figma Auto Layout becomes Flex and Grid in Penpot.</li> + <li><strong>Tokens:</strong> if you already use Tokens Studio, export JSON and import it in Penpot. Native Figma Variables can go through Tokens Studio, or through the Exporter plugin.</li> + <li><strong>Validate before you scale:</strong> migrate one representative file (or a sandbox library), write down recurring cleanup, then roll the same checklist out to the rest of the workspace.</li> + <li><strong>People and pilots:</strong> the second half of the guide has paths for designers, frontend developers, DesignOps, design-system leads, and product/engineering pilots, including how Penpot MCP can help with post-import cleanup.</li> +</ul> + +<p>The guide also covers running Figma and Penpot in parallel for a while. The exporter is for one-off migration, not continuous sync.</p> + +<h2 id="discuss-the-guide">Questions and discussion</h2> +<p>If you want to ask about a migration, or share how yours is going, use the Community post <a href="https://community.penpot.app/t/the-enterprise-guide-to-migrating-design-systems-to-penpot/10768" target="_blank" rel="noopener">The Enterprise Guide to Migrating Design Systems to Penpot</a>.</p> diff --git a/docs/user-guide/first-steps/troubleshooting-webgl.njk b/docs/user-guide/first-steps/troubleshooting-webgl.njk index 7188090ac8..c3893f90e8 100644 --- a/docs/user-guide/first-steps/troubleshooting-webgl.njk +++ b/docs/user-guide/first-steps/troubleshooting-webgl.njk @@ -1,6 +1,6 @@ --- title: Troubleshooting WebGL -order: 5 +order: 7 desc: Diagnose and fix common WebGL issues in Penpot, enable WebGL rendering (Beta), and troubleshoot browser, GPU, and system checks. --- diff --git a/exporter/README.md b/exporter/README.md new file mode 100644 index 0000000000..df4d4412db --- /dev/null +++ b/exporter/README.md @@ -0,0 +1,99 @@ +# Exporter + +Node service that renders shapes and files to bitmap, SVG and PDF. Wasm exports +are **jobs**: created over HTTP, admitted by a scheduler with bounded +concurrency, and persisted in Redis so their state can be queried and cancelled. +The legacy entry point, which is what the browser backend still goes through, +runs the export as soon as it is asked for, with no admission control. + +## HTTP API + +Mounted under `/api/export` (the router matches on the path *after* that prefix, +so it also works when the process is hit directly on `/`). + +| Method | Path | Description | +|----------|-----------------|----------------------------------------------------| +| `POST` | `/` | Legacy command multiplex; runs unscheduled | +| `POST` | `/jobs` | Create an export job | +| `GET` | `/jobs/{id}` | Job record | +| `DELETE` | `/jobs/{id}` | Request cancellation | + +Job states: `queued` -> `running` -> `ended` | `error` | `cancelled`. The last +three are terminal. + +## Redis layout + +Every key is namespaced with `penpot.exporter.` plus the tenant +(`PENPOT_TENANT`, `default` in code but set to the workspace name in devenv, +e.g. `devenv-ws0`). + +``` +penpot.exporter.{tenant}.job.{job-id} hash field: data (transit blob of the + whole record) +penpot.exporter.{tenant}.job-cancel pubsub payload: the job id, one line +``` + +There is no index: the keyspace is one self-expiring hash per job and nothing +else. Each hash carries the same TTL as the exported file +(`PENPOT_EXPORTER_JOB_TTL`, default 3600s), refreshed on every write and never +after the job settles. + +## Inspecting Redis + +Redis is not published on the host, so `redis-cli` from your machine gets +connection refused. Run it **inside the devenv container**, against the `valkey` +host on database 0: + +```bash +redis-cli -h valkey -n 0 +``` + +`redis-cli -u "$PENPOT_REDIS_URI"` does the same and follows whatever the env is +set to (`redis://valkey/0` in devenv). + +Keys carry the tenant, which in devenv is the **workspace name** +(`$PENPOT_TENANT`, e.g. `devenv-ws0`), not `default`. From the prompt: + +``` +# every job record +KEYS penpot.exporter.devenv-ws0.job.* + +# the whole record, transit-json in the `data` field +HGET penpot.exporter.devenv-ws0.job.<job-id> data + +# seconds left before the record expires +TTL penpot.exporter.devenv-ws0.job.<job-id> + +# watch cancellations as they are published (blocks the connection) +SUBSCRIBE penpot.exporter.devenv-ws0.job-cancel + +# drop one record +DEL penpot.exporter.devenv-ws0.job.<job-id> +``` + +`KEYS` is fine here -- the keyspace is a handful of job hashes. On a real +deployment use `SCAN 0 MATCH penpot.exporter.<tenant>.job.* COUNT 100` instead. +Do not `FLUSHDB`: the backend shares this database. + +The backend debug UI also renders these records: `/dbg` has an *Export jobs* +section, with a `?job-id=` filter. + +## Configuration + +| Variable | Default | Description | +|---------------------------------------|---------|--------------------------------------| +| `PENPOT_REDIS_URI` | `redis://redis/0` | Job store and cancel topic | +| `PENPOT_TENANT` | `default` | Key and topic prefix | +| `PENPOT_EXPORTER_JOB_TTL` | `3600` | Lifetime of a job record, in seconds | +| `PENPOT_EXPORTER_MAX_CONCURRENT_JOBS` | `4` | Admission limit | +| `PENPOT_EXPORTER_MAX_JOBS_PER_PROFILE`| `2` | Per-profile admission limit | +| `PENPOT_EXPORTER_QUEUE_MAX` | `64` | Queue cap; over it, `429 :queue-full` | +| `PENPOT_WASM_WORKER_POOL_MAX` | `2` | Headless render worker threads; min 1 | +| `PENPOT_WASM_WORKER_POOL_MIN` | `1` | Workers kept warm; clamped to the max | +| `PENPOT_WASM_WORKER_IDLE_TIMEOUT` | `300` | Silence before a worker is terminated, in seconds | +| `PENPOT_WASM_WORKER_IMAGE_CACHE_SIZE` | `134217728` | Per-worker image cache budget, in bytes | + +A headless job leases one render worker for its whole run, so it is admitted +only when a worker is free: `PENPOT_WASM_WORKER_POOL_MAX` is the real limit for +them, and `PENPOT_EXPORTER_MAX_CONCURRENT_JOBS` bounds the browser ones +alongside. diff --git a/exporter/deps.edn b/exporter/deps.edn index 9495142719..4d8391f6de 100644 --- a/exporter/deps.edn +++ b/exporter/deps.edn @@ -14,7 +14,7 @@ :dev {:extra-deps - {thheller/shadow-cljs {:mvn/version "3.4.11"}}} + {thheller/shadow-cljs {:mvn/version "3.5.0"}}} :shadow-cljs {:main-opts ["-m" "shadow.cljs.devtools.cli"] diff --git a/exporter/package.json b/exporter/package.json index 5b67de97f1..d17104e0b5 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" @@ -17,25 +17,29 @@ "date-fns": "^4.4.0", "generic-pool": "^3.9.0", "inflation": "^2.1.0", - "ioredis": "^5.11.1", + "ioredis": "^6.0.0", "playwright": "1.62.1", "raw-body": "^4.0.0", "source-map-support": "^0.5.21", - "undici": "^8.9.0", + "undici": "^8.10.0", "xml-js": "^1.6.11", "xregexp": "^5.1.2" }, "devDependencies": { - "ws": "^8.21.1" + "ws": "^8.21.3" }, "scripts": { "clear:shadow-cache": "rm -rf .shadow-cljs && rm -rf target", "watch:app": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs watch main", "watch": "pnpm run watch:app", "build:app": "clojure -M:dev:shadow-cljs release main", + "build:wasm": "../render-wasm/build export", "build": "pnpm run clear:shadow-cache && pnpm run build:app", - "fmt": "cljfmt fix --parallel=true src/", - "check-fmt": "cljfmt check --parallel=true src/", - "lint": "clj-kondo --parallel --lint src/" + "fmt:clj": "cljfmt fix --parallel=true src/ test/", + "check-fmt:clj": "cljfmt check --parallel=true src/ test/", + "lint:clj": "clj-kondo --parallel --lint src/ test/", + "build:test": "clojure -M:dev:shadow-cljs compile test", + "test": "pnpm run build:test && PENPOT_SECRET_KEY=${PENPOT_SECRET_KEY:-test-secret-key} node target/tests/test.js", + "test:quiet": "node ./scripts/test-quiet.js" } } diff --git a/exporter/pnpm-lock.yaml b/exporter/pnpm-lock.yaml index 7066611ba6..02f1974302 100644 --- a/exporter/pnpm-lock.yaml +++ b/exporter/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- lockfileVersion: '9.0' settings: @@ -33,8 +134,8 @@ importers: specifier: ^2.1.0 version: 2.1.0 ioredis: - specifier: ^5.11.1 - version: 5.11.1 + specifier: ^6.0.0 + version: 6.0.0 playwright: specifier: 1.62.1 version: 1.62.1 @@ -45,8 +146,8 @@ importers: specifier: ^0.5.21 version: 0.5.21 undici: - specifier: ^8.9.0 - version: 8.9.0 + specifier: ^8.10.0 + version: 8.10.0 xml-js: specifier: ^1.6.11 version: 1.6.11 @@ -55,8 +156,8 @@ importers: version: 5.1.2 devDependencies: ws: - specifier: ^8.21.1 - version: 8.21.1 + specifier: ^8.21.3 + version: 8.21.3 packages: @@ -64,8 +165,8 @@ packages: resolution: {integrity: sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ==} engines: {node: '>=6.9.0'} - '@ioredis/commands@1.10.0': - resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@ioredis/commands@2.0.0': + resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==} '@penpot/svgo@https://codeload.github.com/penpot/svgo/tar.gz/65b3a645df9edbe3c00acf4267dd06c2ca736021': resolution: {gitHosted: true, integrity: sha512-hG/pgVEWhmHEFMU+evGZkB5kHauff5Zo6ZO+Ro7HY0efsQTJft6svM4isH5jDISeSVrZ1CDGnhWBXuqkztsTWw==, tarball: https://codeload.github.com/penpot/svgo/tar.gz/65b3a645df9edbe3c00acf4267dd06c2ca736021} @@ -142,9 +243,9 @@ packages: resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} engines: {node: '>=20.19.0'} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} buffer-crc32@1.0.0: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} @@ -284,9 +385,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ioredis@5.11.1: - resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} - engines: {node: '>=12.22.0'} + ioredis@6.0.0: + resolution: {integrity: sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==} + engines: {node: '>=20.0.0'} is-stream@4.0.1: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} @@ -363,10 +464,6 @@ packages: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} - redis-parser@3.0.0: - resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} - engines: {node: '>=4'} - safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -424,15 +521,15 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} - undici@8.9.0: - resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} engines: {node: '>=22.19.0'} util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - ws@8.21.1: - resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -460,7 +557,7 @@ snapshots: dependencies: core-js-pure: 3.49.0 - '@ioredis/commands@1.10.0': {} + '@ioredis/commands@2.0.0': {} '@penpot/svgo@https://codeload.github.com/penpot/svgo/tar.gz/65b3a645df9edbe3c00acf4267dd06c2ca736021': dependencies: @@ -532,7 +629,7 @@ snapshots: boolbase@2.0.0: {} - brace-expansion@5.0.6: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -658,14 +755,13 @@ snapshots: inherits@2.0.4: {} - ioredis@5.11.1: + ioredis@6.0.0: dependencies: - '@ioredis/commands': 1.10.0 + '@ioredis/commands': 2.0.0 cluster-key-slot: 1.1.1 debug: 4.4.3 denque: 2.1.0 redis-errors: 1.2.0 - redis-parser: 3.0.0 standard-as-callback: 2.1.0 transitivePeerDependencies: - supports-color @@ -690,7 +786,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.9 ms@2.1.3: {} @@ -741,10 +837,6 @@ snapshots: redis-errors@1.2.0: {} - redis-parser@3.0.0: - dependencies: - redis-errors: 1.2.0 - safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} @@ -811,11 +903,11 @@ snapshots: tsscmp@1.0.6: {} - undici@8.9.0: {} + undici@8.10.0: {} util-deprecate@1.0.2: {} - ws@8.21.1: {} + ws@8.21.3: {} xml-js@1.6.11: dependencies: diff --git a/exporter/pnpm-workspace.yaml b/exporter/pnpm-workspace.yaml index ef7ae17b58..ac94ff8d53 100644 --- a/exporter/pnpm-workspace.yaml +++ b/exporter/pnpm-workspace.yaml @@ -4,6 +4,7 @@ minimumReleaseAgeExclude: - lodash@4.17.23 || 4.17.24 - playwright-core@1.62.1 - playwright@1.62.1 + - brace-expansion@5.0.7 || 5.0.8 || 5.0.9 overrides: lodash@<=4.17.23: ^4.17.24 lodash@>=4.0.0 <=4.17.22: ^4.17.23 diff --git a/exporter/scripts/build b/exporter/scripts/build index 40eba8f44c..a240c17b75 100755 --- a/exporter/scripts/build +++ b/exporter/scripts/build @@ -8,6 +8,17 @@ export NODE_ENV=production; corepack enable; corepack install || exit 1; pnpm install || exit 1; +pnpm run build:wasm; + +WASM_SRC="resources/wasm"; +WASM_SHARED="src/app/wasm/shared.js"; +if [ ! -f "$WASM_SRC/render-wasm.wasm" ] || [ ! -f "$WASM_SHARED" ]; then + echo "ERROR: the render-wasm build did not produce:" >&2; + echo " $WASM_SRC/render-wasm.wasm" >&2; + echo " $WASM_SHARED" >&2; + exit 1; +fi + rm -rf target # Build the application @@ -18,6 +29,9 @@ cp pnpm-workspace.yaml target/; cp package.json target/; touch target/pnpm-workspace.yaml; +mkdir -p target/$WASM_SRC; +cp "$WASM_SRC/render-wasm.js" "$WASM_SRC/render-wasm.wasm" target/$WASM_SRC/; + cat <<EOF | tee target/setup #/usr/bin/env bash set -e; diff --git a/exporter/scripts/test b/exporter/scripts/test new file mode 100755 index 0000000000..6402c5afd1 --- /dev/null +++ b/exporter/scripts/test @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -ex +corepack enable; +corepack install; +pnpm install; +pnpm run test; diff --git a/exporter/scripts/test-quiet.js b/exporter/scripts/test-quiet.js new file mode 100644 index 0000000000..b1be0dd682 --- /dev/null +++ b/exporter/scripts/test-quiet.js @@ -0,0 +1,29 @@ +import { spawnSync } from "node:child_process"; + +const BUILD_STEPS = [ + { label: "Building test bundle", cmd: "pnpm", args: ["run", "build:test"] }, +]; + +const progress = (msg) => process.stderr.write(`${msg}\n`); + +for (const step of BUILD_STEPS) { + progress(`${step.label}...`); + const result = spawnSync(step.cmd, step.args, { + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 64 * 1024 * 1024, + }); + if (result.status !== 0) { + progress(`${step.label} failed`); + if (result.stdout?.length) process.stdout.write(result.stdout); + if (result.stderr?.length) process.stderr.write(result.stderr); + process.exit(result.status ?? 1); + } +} + +progress("Running tests..."); +const result = spawnSync( + "node", + ["target/tests/test.js", ...process.argv.slice(2)], + { stdio: "inherit" }, +); +process.exit(result.status ?? 1); diff --git a/exporter/shadow-cljs.edn b/exporter/shadow-cljs.edn index ae963cf311..076e6a10f6 100644 --- a/exporter/shadow-cljs.edn +++ b/exporter/shadow-cljs.edn @@ -31,4 +31,12 @@ :pseudo-names true :pretty-print true :anon-fn-naming-policy :off - :source-map-detail-level :all}}}}} + :source-map-detail-level :all}}} + + :test + {:target :esm + :output-dir "target/tests" + :runtime :node + :js-options {:js-provider :import} + :modules + {:test {:init-fn exporter-tests.runner/-main}}}}} diff --git a/exporter/src/app/auth.cljs b/exporter/src/app/auth.cljs new file mode 100644 index 0000000000..d6ac0cf042 --- /dev/null +++ b/exporter/src/app/auth.cljs @@ -0,0 +1,98 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.auth + "Resolves the caller's session cookie to a real profile id. + + The export commands take `:profile-id` from the request body, which was + harmless while it only picked a pub/sub topic. The job API can read and + cancel other people's work, so its ownership comes from the session: the + token goes to the backend's `get-profile` command, which answers with the + anonymous profile (`uuid/zero`) when it is not a valid session. + + Results are memoized briefly, so a burst of export calls from one client is + one round trip rather than one per request." + (:require + ["undici" :as http] + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.common.transit :as t] + [app.common.uri :as u] + [app.common.uuid :as uuid] + [app.config :as cf] + [promesa.core :as p])) + +(def ^:private cache-ttl-ms 60000) + +(defonce ^:private cache (atom {})) + +(defn- put-in-cache + "Stores the resolution by session token and removes expired entries. + Without cleanup, long-lived exporters accumulate stale entries" + [cache token profile-id now] + (-> (into {} (remove (fn [[_ {:keys [expires-at]}]] (<= expires-at now))) cache) + (assoc token {:profile-id profile-id + :expires-at (+ now cache-ttl-ms)}))) + +(defn- rpc-uri + [] + (-> (cf/get-internal-uri) + (u/ensure-path-slash) + (u/join "api/rpc/command/get-profile") + (str))) + +(defn- fetch-profile-id + [token] + (let [uri (rpc-uri) + headers #js {"Content-Type" "application/transit+json" + "X-Shared-Key" (str "exporter " cf/management-key) + "Cookie" (str "auth-token=" token)}] + (->> (p/do (http/fetch uri #js {:method "POST" :headers headers :body (t/encode-str {})})) + (p/mcat (fn [^js resp] + (if (= 200 (.-status resp)) + (.text resp) + (p/resolved nil)))) + (p/fmap (fn [body] + (some-> body t/decode-str :id))) + (p/merr (fn [cause] + (l/warn :hint "unable to resolve session profile" :uri uri :cause cause) + (p/resolved nil)))))) + +(defn resolve-profile-id + "Promise of the authenticated profile id, or nil for an anonymous or absent + session." + [token] + (if (nil? token) + (p/resolved nil) + (let [{:keys [profile-id expires-at]} (get @cache token)] + (if (and expires-at (> expires-at (js/Date.now))) + (p/resolved profile-id) + (->> (fetch-profile-id token) + (p/fmap (fn [profile-id] + (let [profile-id (when (and profile-id (not= uuid/zero profile-id)) profile-id)] + (swap! cache put-in-cache token profile-id (js/Date.now)) + profile-id)))))))) + +(defn require-profile-id + "Like `resolve-profile-id`, but rejects anonymous callers." + [token] + (->> (resolve-profile-id token) + (p/mcat (fn [profile-id] + (if profile-id + (p/resolved profile-id) + (ex/raise :type :authentication + :code :authentication-required + :hint "no valid session for this request")))))) + +(defn check-owner! + "Raises unless `profile-id` owns `job`." + [job profile-id] + (when (or (nil? job) + (not= (str (:profile-id job)) (str profile-id))) + (ex/raise :type :not-found + :code :object-not-found + :hint "job does not exist")) + job) diff --git a/exporter/src/app/browser.cljs b/exporter/src/app/browser.cljs index fc364494e1..9a02e0a5ae 100644 --- a/exporter/src/app/browser.cljs +++ b/exporter/src/app/browser.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.browser (:require diff --git a/exporter/src/app/config.cljs b/exporter/src/app/config.cljs index 127a28fa99..0fcc5a052d 100644 --- a/exporter/src/app/config.cljs +++ b/exporter/src/app/config.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.config (:refer-clojure :exclude [get]) @@ -28,7 +28,15 @@ :http-server-port 6061 :http-server-host "0.0.0.0" :tempdir "/tmp/penpot" - :redis-uri "redis://redis/0"}) + :redis-uri "redis://redis/0" + :exporter-max-concurrent-jobs 4 + :exporter-max-jobs-per-profile 2 + :exporter-queue-max 64 + :exporter-job-ttl 3600 + :wasm-worker-pool-max 2 + :wasm-worker-pool-min 1 + :wasm-worker-idle-timeout 300 + :wasm-worker-image-cache-size (* 128 1024 1024)}) (def ^:private schema:config [:map {:title "config"} @@ -42,7 +50,15 @@ [:redis-uri {:optional true} :string] [:tempdir {:optional true} :string] [:browser-pool-max {:optional true} ::sm/int] - [:browser-pool-min {:optional true} ::sm/int]]) + [:browser-pool-min {:optional true} ::sm/int] + [:exporter-max-concurrent-jobs {:optional true} ::sm/int] + [:exporter-max-jobs-per-profile {:optional true} ::sm/int] + [:exporter-queue-max {:optional true} ::sm/int] + [:exporter-job-ttl {:optional true} ::sm/int] + [:wasm-worker-pool-max {:optional true} ::sm/int] + [:wasm-worker-pool-min {:optional true} ::sm/int] + [:wasm-worker-idle-timeout {:optional true} ::sm/int] + [:wasm-worker-image-cache-size {:optional true} ::sm/int]]) (def ^:private decode-config (sm/decoder schema:config sm/string-transformer)) diff --git a/exporter/src/app/core.cljs b/exporter/src/app/core.cljs index 5ce68ebf98..ec04fa7685 100644 --- a/exporter/src/app/core.cljs +++ b/exporter/src/app/core.cljs @@ -2,49 +2,93 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.core (:require ["node:process" :as proc] + ["node:worker_threads" :as wt] [app.browser :as bwr] [app.common.logging :as l] [app.config :as cf] [app.http :as http] + [app.jobs :as jobs] + [app.jobs.utils :as job.utils] [app.redis :as redis] + [app.wasm :as wasm] + [app.wasm.pool :as wasm.pool] + [app.wasm.worker :as wasm.worker] [promesa.core :as p])) (enable-console-print!) (l/setup! {:app :info}) (defn start + "Render workers run this same bundle, so the thread decides what gets booted: + the http server and its pools, or one render worker." [& _] - (l/info :msg "initializing" - :public-uri (str (cf/get :public-uri)) - :internal-uri (str (cf/get-internal-uri)) - :version (:full cf/version)) - (p/do! - (bwr/init) - (redis/init) - (http/init))) + (if-not ^boolean wt/isMainThread + (wasm.worker/main) + (do + (l/info :msg "initializing" + :public-uri (str (cf/get :public-uri)) + :internal-uri (str (cf/get-internal-uri)) + :version (:full cf/version)) + (when (contains? cf/flags :wasm-export) + (l/info :msg "headless wasm export enabled (experimental)" + :wasm-dir wasm/artifact-dir + :workers (cf/get :wasm-worker-pool-max) + :image-cache-size (cf/get :wasm-worker-image-cache-size))) + (p/do + (bwr/init) + (redis/init) + (jobs/init) + (job.utils/init) + (wasm.pool/init) + (http/init))))) (def main start) +;; Draining a pool waits for every checked-out resource to come back, which an +;; export in flight can hold for as long as its own timeout. On a hot reload +;; that would block `start` from ever running again, leaving a drained pool that +;; fails every later job. +(def ^:private shutdown-step-timeout 3000) + +(defn- shutdown-step + [label f] + (-> (p/race [(p/do (f)) + (p/fmap (constantly ::timeout) (p/delay shutdown-step-timeout))]) + (p/handle (fn [result cause] + (when (or (some? cause) (= ::timeout result)) + (l/warn :hint "shutdown step did not finish cleanly" + :step label + :cause cause)) + nil)))) + (defn stop [done] ;; an empty line for visual feedback of restart (js/console.log "") - (l/info :msg "stopping") - (p/do! - (bwr/stop) - (redis/stop) - (http/stop) - (done))) + (if-not ^boolean wt/isMainThread + ;; A render worker owns no server, pools or connections; nothing to unwind. + (done) + (do + (l/info :msg "stopping") + (p/do + (shutdown-step "browser-pool" bwr/stop) + (shutdown-step "wasm-worker-pool" wasm.pool/stop) + (shutdown-step "redis" redis/stop) + (shutdown-step "http" http/stop) + (done))))) (.on proc/default "uncaughtException" (fn [cause] (js/console.error cause))) -(.on proc/default "SIGTERM" (fn [] (proc/exit 0))) -(.on proc/default "SIGINT" (fn [] (proc/exit 0))) +;; Signals are only delivered to the main thread, and `exit` in a worker would +;; take down that worker rather than the process. +(when ^boolean wt/isMainThread + (.on proc/default "SIGTERM" (fn [] (proc/exit 0))) + (.on proc/default "SIGINT" (fn [] (proc/exit 0)))) diff --git a/exporter/src/app/handlers.cljs b/exporter/src/app/handlers.cljs index cc97d93cdb..bd2b453951 100644 --- a/exporter/src/app/handlers.cljs +++ b/exporter/src/app/handlers.cljs @@ -2,26 +2,48 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.handlers (:require + [app.auth :as auth] [app.common.data :as d] - [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.spec :as us] - [app.handlers.export-frames :as export-frames] - [app.handlers.export-shapes :as export-shapes] + [app.handlers.export :as export] [app.util.transit :as t] [clojure.spec.alpha :as s] - [cuerdas.core :as str])) + [promesa.core :as p])) (l/set-level! :debug) +(def ^:private error-codes + #{:queue-full}) + (defn on-error [error exchange] (let [{:keys [type code] :as data} (ex-data error)] (cond + (and (= :validation type) + (contains? error-codes code)) + (let [data {:type :validation + :code code + :hint (ex-message error)}] + (l/warn :hint "rejecting export request" :code code) + (-> exchange + (assoc :response/status 429) + (assoc :response/body (t/encode data)) + (assoc :response/headers {"content-type" "application/transit+json"}))) + + (= :authentication type) + (let [data {:type :authentication + :code code + :hint (ex-message error)}] + (-> exchange + (assoc :response/status 401) + (assoc :response/body (t/encode data)) + (assoc :response/headers {"content-type" "application/transit+json"}))) + (or (= :validation type) (= :assertion type)) (let [explain (us/pretty-explain data) @@ -62,27 +84,27 @@ (assoc :response/body (t/encode (d/without-nils data))) (assoc :response/headers {"content-type" "application/transit+json"})))))) -(defmulti command-spec :cmd) - -(s/def ::id ::us/string) -(s/def ::wait ::us/boolean) -(s/def ::cmd ::us/keyword) - -(defmethod command-spec :export-shapes [_] ::export-shapes/params) -(defmethod command-spec :export-frames [_] ::export-frames/params) - -(s/def ::params - (s/and (s/keys :req-un [::cmd] - :opt-un [::wait]) - (s/multi-spec command-spec :cmd))) - (defn handler - [{:keys [:request/params] :as exchange}] - (let [{:keys [cmd] :as params} (us/conform ::params params)] + "The original `POST /api/export` entry point, and the one the browser backend + still goes through. The export runs as soon as it is asked for, and the + contract is unchanged: `:wait` answers with the finished resource, otherwise + with the resource handle while the work runs." + [{:keys [:request/params :request/auth-token] :as exchange}] + (let [{:keys [cmd wait] :as params} (export/conform-params params)] (l/debug :hint "process-request" :cmd cmd) - (case cmd - :export-shapes (export-shapes/handler exchange params) - :export-frames (export-frames/handler exchange params) - (ex/raise :type :internal - :code :method-not-implemented - :hint (str/istr "method ~{cmd} not implemented"))))) + (->> (auth/resolve-profile-id auth-token) + (p/mcat (fn [profile-id] + ;; The session wins when there is one; the body value stays + ;; the fallback so nothing that used to work stops working. + (export/export! auth-token (cond-> params + (some? profile-id) + (assoc :profile-id profile-id))))) + (p/mcat (fn [{:keys [resource pending]}] + (if wait + (p/fmap (fn [resource] + (assoc exchange :response/body resource)) + pending) + (do + (p/merr (constantly nil) pending) + (p/resolved + (assoc exchange :response/body (dissoc resource :path)))))))))) diff --git a/exporter/src/app/handlers/export.cljs b/exporter/src/app/handlers/export.cljs new file mode 100644 index 0000000000..a7b309f652 --- /dev/null +++ b/exporter/src/app/handlers/export.cljs @@ -0,0 +1,103 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.handlers.export + "Handle export jobs" + (:require + [app.common.spec :as us] + [app.handlers.export-frames :as export-frames] + [app.handlers.export-shapes :as export-shapes] + [app.jobs :as jobs] + [app.jobs.scheduler :as scheduler] + [app.jobs.utils :as job.utils] + [clojure.spec.alpha :as s] + [promesa.core :as p])) + +;; --- PARAMS + +(defmulti command-spec :cmd) + +(s/def ::cmd ::us/keyword) +(s/def ::wait ::us/boolean) + +(defmethod command-spec :export-shapes [_] ::export-shapes/params) +(defmethod command-spec :export-frames [_] ::export-frames/params) + +(s/def ::params + (s/and (s/keys :req-un [::cmd] + :opt-un [::wait]) + (s/multi-spec command-spec :cmd))) + +(defn conform-params + [params] + (us/conform ::params params)) + +(defn- prepare + [cmd auth-token params] + (case cmd + :export-shapes (export-shapes/prepare auth-token params) + :export-frames (export-frames/prepare auth-token params))) + +(defn- current + [job] + (or (jobs/lookup (:id job)) job)) + +(defn- run-and-track + [job run] + (->> (p/do (run job)) + (p/mcat (fn [resource] + (->> (jobs/complete! (current job) resource) + (p/fmap (constantly resource))))) + (p/merr (fn [cause] + (if (jobs/cancelled? (:id job)) + (p/rejected cause) + (->> (jobs/fail! (current job) cause) + (p/mcat (fn [_] (p/rejected cause))))))))) + +(defn- run-now! + "Runs the job as soon as it is created, outside the scheduler." + [job] + (->> (p/do (jobs/start! job)) + (p/mcat (fn [job] (p/do ((jobs/run-fn (:id job)) job)))) + (p/fnly (fn [_ _] + (jobs/release! (:id job)) + (job.utils/release! (:id job)))))) + +(defn- create! + [auth-token {:keys [cmd profile-id] :as params} start] + (let [{:keys [resource total headless run]} (prepare cmd auth-token params)] + (->> (jobs/create! {:profile-id profile-id + :cmd cmd + ;; What the renderer will actually do, not what the + ;; client asked for: `is-wasm` alone still renders in + ;; the browser without the `wasm-export` flag, or for + ;; svg, and the backend decides both the admission cap + ;; and whether the client offers to cancel. + :backend (if headless "wasm" "browser") + :total total + :name (:name resource) + :resource-id (:id resource)} + (fn [job] (run-and-track job run))) + (p/fmap (fn [job] + (try + {:job job + :resource resource + :pending (start job)} + (catch :default cause + (jobs/fail! job cause) + (jobs/release! (:id job)) + (throw cause)))))))) + +(defn create-job! + "Returns a promise of `{:job :resource :pending}`." + [auth-token params] + (create! auth-token params scheduler/submit!)) + +(defn export! + "Like `create-job!`, but the work starts right away: This is what + keeps browser exports behaving exactly as they did before there were jobs." + [auth-token params] + (create! auth-token params run-now!)) diff --git a/exporter/src/app/handlers/export_frames.cljs b/exporter/src/app/handlers/export_frames.cljs index cf263d8e00..ed290d4b50 100644 --- a/exporter/src/app/handlers/export_frames.cljs +++ b/exporter/src/app/handlers/export_frames.cljs @@ -2,23 +2,21 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.handlers.export-frames (:require - [app.common.logging :as l] [app.common.spec :as us] - [app.handlers.export-shapes :refer [prepare-exports]] + [app.handlers.export-shapes :refer [count-objects headless-exports? prepare-exports]] [app.handlers.resources :as rsc] - [app.redis :as redis] + [app.jobs :as jobs] + [app.jobs.utils :as job.utils] [app.renderer :as rd] [app.util.shell :as sh] [cljs.spec.alpha :as s] [cuerdas.core :as str] [promesa.core :as p])) -(declare ^:private handle-export) -(declare ^:private create-pdf) (declare ^:private join-pdf) (declare ^:private move-file) @@ -38,86 +36,54 @@ (s/keys :req-un [::exports] :opt-un [::name ::is-wasm])) -(defn handler - [{:keys [:request/auth-token] :as exchange} {:keys [exports] :as params}] - ;; NOTE: we need to have the `:type` prop because the exports - ;; datastructure preparation uses it for creating the groups. - (let [exports (-> (map #(assoc % :type :pdf :scale 1 :suffix "") exports) - (prepare-exports auth-token))] - - (handle-export exchange (assoc params :exports exports)))) - -(defn handle-export - [{:keys [:request/auth-token] :as exchange} {:keys [exports name profile-id is-wasm] :as params}] - (let [topic (str profile-id) - file-id (-> exports first :file-id) - - resource - (rsc/create :pdf (or name (-> exports first :name))) - - on-progress - (fn [done] - (let [data {:type :export-update - :resource-id (:id resource) - :status "running" - :done done}] - (redis/pub! topic data))) - - on-complete - (fn [resource] - (let [data {:type :export-update - :resource-id (:id resource) - :resource-uri (:uri resource) - :name (:name resource) - :filename (:filename resource) - :mtype (:mtype resource) - :status "ended"}] - (redis/pub! topic data))) - - on-error - (fn [cause] - (l/error :hint "unexpected error on frames exportation" :cause cause) - (let [data {:type :export-update - :resource-id (:id resource) - :name (:name resource) - :filename (:filename resource) - :status "error" - :cause (ex-message cause)}] - (redis/pub! topic data))) - - result-cache - (atom []) +(defn- run-export + [job auth-token resource {:keys [exports is-wasm file-id]}] + (let [rendered (atom []) on-object - (fn [{:keys [path] :as object}] - (let [res (swap! result-cache conj path)] - (on-progress (count res)))) + (fn [{:keys [path] :as _object}] + (job.utils/track! (:id job) path) + (jobs/progress! job (count (swap! rendered conj path)))) - procs - (->> (seq exports) - (map #(rd/render (assoc % :is-wasm is-wasm) on-object)))] + exports + (map #(assoc % :is-wasm is-wasm :job-id (:id job)) exports)] - (->> (p/all procs) - (p/fmap (fn [] @result-cache)) - (p/mcat (partial join-pdf file-id)) + (job.utils/track! (:id job) (:path resource)) + (->> (rd/with-scope exports + (fn [render] + (jobs/check-cancelled! job) + (->> exports + (map (fn [export] (render export on-object))) + (p/all)))) + (p/fmap (fn [_] @rendered)) + (p/mcat (partial join-pdf job file-id)) (p/mcat (partial move-file resource)) (p/fmap (constantly resource)) (p/mcat (partial rsc/upload-resource auth-token)) (p/mcat (fn [resource] (->> (sh/stat (:path resource)) (p/fmap #(merge resource %))))) - (p/merr on-error) - (p/fnly (fn [resource cause] - (when-not cause - (on-complete resource))))) + (p/fmap (fn [resource] (dissoc resource :path)))))) - (assoc exchange :response/body (dissoc resource :path)))) +(defn prepare + [auth-token {:keys [exports name is-wasm] :as _params}] + (let [exports (-> (map #(assoc % :type :pdf :scale 1 :suffix "") exports) + (prepare-exports auth-token is-wasm)) + resource (rsc/create :pdf (or name (-> exports first :name))) + file-id (-> exports first :file-id)] + {:resource resource + :total (count-objects exports) + :headless (headless-exports? exports is-wasm) + :run (fn [job] (run-export job auth-token resource + {:exports exports + :is-wasm is-wasm + :file-id file-id}))})) (defn- join-pdf - [file-id paths] + [job file-id paths] (p/let [prefix (str/concat "penpot.pdfunite." file-id ".") - path (sh/tempfile :prefix prefix :suffix ".pdf")] - (sh/run-cmd! (str "pdfunite " (str/join " " paths) " " path)) + path (job.utils/track! (:id job) (sh/tempfile :prefix prefix :suffix ".pdf"))] + (apply sh/run-cmd! "pdfunite" (conj (vec paths) path)) path)) (defn- move-file diff --git a/exporter/src/app/handlers/export_shapes.cljs b/exporter/src/app/handlers/export_shapes.cljs index 6213281453..954285ba1e 100644 --- a/exporter/src/app/handlers/export_shapes.cljs +++ b/exporter/src/app/handlers/export_shapes.cljs @@ -2,15 +2,15 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.handlers.export-shapes (:require [app.common.data :as d] - [app.common.logging :as l] [app.common.spec :as us] [app.handlers.resources :as rsc] - [app.redis :as redis] + [app.jobs :as jobs] + [app.jobs.utils :as job.utils] [app.renderer :as rd] [app.util.mime :as mime] [app.util.shell :as sh] @@ -18,9 +18,6 @@ [cuerdas.core :as str] [promesa.core :as p])) -(declare ^:private handle-single-export) -(declare ^:private handle-multiple-export) -(declare ^:private assoc-file-name) (declare prepare-exports) ;; Regex to clean namefiles @@ -50,87 +47,92 @@ (s/keys :req-un [::exports ::profile-id] :opt-un [::wait ::name ::skip-children ::force-multiple ::is-wasm])) -(defn handler - [{:keys [:request/auth-token] :as exchange} {:keys [exports force-multiple] :as params}] - (let [exports (prepare-exports exports auth-token)] - (if (and (not force-multiple) - (= 1 (count exports)) - (= 1 (count (-> exports first :objects)))) - (handle-single-export exchange (-> params - (assoc :export (first exports)) - (dissoc :exports))) - (handle-multiple-export exchange (assoc params :exports exports))))) +(defn count-objects + [exports] + (reduce + 0 (map (comp count :objects) exports))) -(defn- handle-single-export - [{:keys [:request/auth-token] :as exchange} {:keys [export name skip-children is-wasm] :as params}] - (let [resource (rsc/create (:type export) (or name (:name export))) - export (assoc export :skip-children skip-children :is-wasm (boolean is-wasm))] +(defn- render! + [job export on-object] + (jobs/check-cancelled! job) + (rd/render (assoc export :job-id (:id job)) on-object)) - (->> (rd/render export - (fn [{:keys [path] :as object}] - (sh/move! path (:path resource)))) +(defn- scoped-renders + "Renders every export, the headless ones sharing a single worker." + [job exports on-object] + (rd/with-scope exports + (fn [render] + (jobs/check-cancelled! job) + (->> exports + (map (fn [export] (render export on-object))) + (p/all))))) + +(defn- run-single + [job auth-token resource {:keys [export is-wasm skip-children]}] + (job.utils/track! (:id job) (:path resource)) + (->> (render! job + (assoc export :skip-children skip-children :is-wasm (boolean is-wasm)) + (fn [{:keys [path] :as _object}] + (job.utils/track! (:id job) path) + (sh/move! path (:path resource)))) + (p/fmap (constantly resource)) + (p/mcat (partial rsc/upload-resource auth-token)) + (p/fmap (fn [resource] (dissoc resource :path))))) + +(defn- run-multiple + [job auth-token resource {:keys [exports is-wasm]}] + (let [failure (volatile! nil) + + zip (rsc/create-zip :resource resource + :on-error (fn [cause] (vreset! failure cause)) + :on-progress (fn [{:keys [done]}] + (jobs/progress! job done))) + + append (fn [{:keys [filename path] :as _object}] + (job.utils/track! (:id job) path) + (rsc/add-to-zip zip path (str/replace filename sanitize-file-regex "_")))] + + (job.utils/track! (:id job) (:path resource)) + (->> (scoped-renders job + (map #(assoc % :is-wasm (boolean is-wasm) :job-id (:id job)) exports) + append) + (p/mcat (fn [_] + (if-let [cause @failure] + (p/rejected cause) + (rsc/close-zip zip)))) (p/fmap (constantly resource)) (p/mcat (partial rsc/upload-resource auth-token)) - (p/fmap (fn [resource] - (dissoc resource :path))) - (p/fmap (fn [resource] - (assoc exchange :response/body resource))) - (p/merr (fn [cause] - (l/error :hint "unexpected error on single export" - :cause cause) - (p/rejected cause)))))) + (p/fmap (fn [resource] (dissoc resource :path)))))) -(defn- handle-multiple-export - [{:keys [:request/auth-token] :as exchange} {:keys [exports wait profile-id name is-wasm] :as params}] - (let [resource (rsc/create :zip (or name (-> exports first :name))) - total (count exports) - topic (str profile-id) +(defn headless-exports? + "Whether any of `exports` renders headless, and so whether the job leases a + render worker. Mirrors what `rd/with-scope` decides at run time." + [exports is-wasm] + (boolean (some #(rd/headless? {:is-wasm is-wasm :type (:type %)}) exports))) - on-progress (fn [{:keys [done]}] - (when-not wait - (let [data {:type :export-update - :resource-id (:id resource) - :status "running" - :total total - :done done}] - (redis/pub! topic data)))) +(defn prepare + [auth-token {:keys [exports force-multiple name skip-children is-wasm] :as _params}] + (let [exports (prepare-exports exports auth-token is-wasm) + headless? (headless-exports? exports is-wasm) + single? (and (not force-multiple) + (= 1 (count exports)) + (= 1 (count (-> exports first :objects))))] + (if single? + (let [export (first exports) + resource (rsc/create (:type export) (or name (:name export)))] + {:resource resource + :total 1 + :headless headless? + :run (fn [job] (run-single job auth-token resource + {:export export + :is-wasm is-wasm + :skip-children skip-children}))}) - on-error (fn [cause] - (l/error :hint "unexpected error on multiple export" :cause cause) - (if wait - (p/rejected cause) - (redis/pub! topic {:type :export-update - :resource-id (:id resource) - :status "error" - :cause (ex-message cause)}))) - - zip (rsc/create-zip :resource resource - :on-error on-error - :on-progress on-progress) - - append (fn [{:keys [filename path] :as resource}] - (rsc/add-to-zip zip path (str/replace filename sanitize-file-regex "_"))) - - proc (->> exports - (map (fn [export] (rd/render (assoc export :is-wasm (boolean is-wasm)) append))) - (p/all) - (p/mcat (fn [_] (rsc/close-zip zip))) - (p/fmap (constantly resource)) - (p/mcat (partial rsc/upload-resource auth-token)) - (p/fmap (fn [resource] - (let [data {:type :export-update - :name (:name resource) - :filename (:filename resource) - :resource-id (:id resource) - :resource-uri (:uri resource) - :mtype (:mtype resource) - :status "ended"}] - (p/do (redis/pub! topic data) - (assoc exchange :response/body resource))))) - (p/merr on-error))] - (if wait - (p/then proc #(assoc exchange :response/body (dissoc % :path))) - (assoc exchange :response/body (dissoc resource :path))))) + (let [resource (rsc/create :zip (or name (-> exports first :name)))] + {:resource resource + :total (count-objects exports) + :headless headless? + :run (fn [job] (run-multiple job auth-token resource + {:exports exports :is-wasm is-wasm}))})))) (defn- assoc-file-name "A transducer that assocs a candidate filename and avoid duplicates" @@ -160,13 +162,18 @@ default-partition-size 50) (defn prepare-exports - [exports token] - (letfn [(process-group [group] - (sequence (comp (partition-all default-partition-size) - (map process-partition)) - group)) + [exports token is-wasm] + (letfn [(process-group [[part1 :as group]] + ;; The browser renders a partition as a single DOM page, so it is + ;; chunked to bound that page. A wasm export is headless, so + ;; it does not need to be chunked, and can be rendered as a single partition. + (if (rd/headless? {:is-wasm is-wasm :type (:type part1)}) + [(build-render group)] + (sequence (comp (partition-all default-partition-size) + (map build-render)) + group))) - (process-partition [[part1 :as part]] + (build-render [[part1 :as part]] {:file-id (:file-id part1) :page-id (:page-id part1) :share-id (:share-id part1) diff --git a/exporter/src/app/handlers/jobs.cljs b/exporter/src/app/handlers/jobs.cljs new file mode 100644 index 0000000000..eca18e8cca --- /dev/null +++ b/exporter/src/app/handlers/jobs.cljs @@ -0,0 +1,60 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.handlers.jobs + "REST surface for export jobs, under `/api/export/jobs`. + + Ownership always comes from the session (see `app.auth`), never from the + request body." + (:require + [app.auth :as auth] + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.handlers.export :as export] + [app.jobs :as jobs] + [promesa.core :as p])) + +(defn create + [{:keys [:request/auth-token :request/params] :as exchange}] + (->> (auth/require-profile-id auth-token) + (p/mcat (fn [profile-id] + (let [params (-> params + (assoc :profile-id profile-id) + (export/conform-params))] + (l/dbg :hint "create export job" :cmd (:cmd params) :profile-id (str profile-id)) + (export/create-job! auth-token params)))) + (p/fmap (fn [{:keys [job resource pending]}] + ;; A failure is reported through the job record, so the + ;; promise must not surface as an unhandled rejection. + (p/merr (constantly nil) pending) + (-> exchange + (assoc :response/body (-> (or (jobs/lookup (:id job)) job) + (assoc :filename (:filename resource)) + (assoc :mtype (:mtype resource))))))))) + +(defn fetch + [{:keys [:request/auth-token] :as exchange} job-id] + (->> (auth/require-profile-id auth-token) + (p/mcat (fn [profile-id] + (->> (jobs/fetch job-id) + (p/fmap #(auth/check-owner! % profile-id))))) + (p/fmap (fn [job] + (assoc exchange :response/body job))))) + +(defn cancel + [{:keys [:request/auth-token] :as exchange} job-id] + (->> (auth/require-profile-id auth-token) + (p/mcat (fn [profile-id] + (->> (jobs/fetch job-id) + (p/fmap #(auth/check-owner! % profile-id))))) + (p/mcat (fn [job] (jobs/cancel! (:id job)))) + (p/mcat (fn [_] (jobs/fetch job-id))) + (p/fmap (fn [job] + (if job + (assoc exchange :response/body job) + (ex/raise :type :not-found + :code :object-not-found + :hint "job does not exist")))))) diff --git a/exporter/src/app/handlers/resources.cljs b/exporter/src/app/handlers/resources.cljs index 4c4bb7225c..d703821f00 100644 --- a/exporter/src/app/handlers/resources.cljs +++ b/exporter/src/app/handlers/resources.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.handlers.resources "Temporal resources management." diff --git a/exporter/src/app/http.cljs b/exporter/src/app/http.cljs index 824f22d5cd..93100885f0 100644 --- a/exporter/src/app/http.cljs +++ b/exporter/src/app/http.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http (:require @@ -15,6 +15,7 @@ [app.common.transit :as t] [app.config :as cf] [app.handlers :as handlers] + [app.router :as router] [cuerdas.core :as str] [lambdaisland.uri :as u] [promesa.core :as p])) @@ -94,7 +95,7 @@ size (js/Buffer.byteLength data "utf-8")] (-> exchange (assoc :response/body data) - (assoc :response/status 200) + (assoc :response/status (or status 200)) (update :response/headers assoc "content-type" "application/transit+json") (update :response/headers assoc "content-length" size))) @@ -159,7 +160,7 @@ (defn init [] - (let [handler (-> handlers/handler + (let [handler (-> (router/create handlers/handler) (wrap-health) (wrap-auth "auth-token") (wrap-response-format) diff --git a/exporter/src/app/jobs.cljs b/exporter/src/app/jobs.cljs new file mode 100644 index 0000000000..2630c847c7 --- /dev/null +++ b/exporter/src/app/jobs.cljs @@ -0,0 +1,284 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.jobs + "Export job model and lifecycle. + + The record is persisted in redis (`app.jobs.store`); the runtime bits that + cannot be serialized -- cancel callbacks, the cancel signal shared with a + render worker, the throttling bookkeeping -- stay in this process, keyed by + job id. + + Every state change also publishes the same `:export-update` message the + exporter has always published, so websocket clients keep working unchanged." + (:require + [app.common.data :as d] + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.jobs.store :as store] + [app.redis :as redis] + [promesa.core :as p])) + +(l/set-level! :debug) + +;; A large export reports progress per object; persisting each one would be +;; hundreds of writes for information nobody reads at that resolution. +(def ^:private progress-throttle-ms 250) + +(def ^:private terminal-states #{"ended" "error" "cancelled"}) + +(defonce ^:private registry (atom {})) + +(defn- now-ms + [] + (inst-ms (ct/now))) + +(defn- runtime + [job-id] + (get @registry (str job-id))) + +(defn lookup + "The live record of a job running in this process, or nil." + [job-id] + (:job (runtime job-id))) + +(defn fetch + "The job record from the shared store." + [job-id] + (store/fetch job-id)) + +(defn- publish! + [{:keys [id profile-id resource-id state done total name filename mtype + resource-uri error]}] + (redis/pub! (redis/->tenant-key (str profile-id)) + (d/without-nils + {:type :export-update + :job-id id + :resource-id resource-id + :status state + :done done + :total total + :name name + :filename filename + :mtype mtype + :resource-uri resource-uri + :cause error}))) + +(defn- store-job! + [job] + (swap! registry update (str (:id job)) assoc :job job) + job) + +(defn create! + "Builds a queued job and persists it. `run-fn` is a 1-arg fn of the job that + performs the export and returns a promise; the caller decides when to run + it." + [{:keys [profile-id cmd backend total name resource-id]} run-fn] + (let [job {:id (uuid/next) + :profile-id profile-id + :cmd cmd + :backend backend + :state "queued" + :done 0 + :total total + :name name + :resource-id resource-id + :created-at (now-ms)}] + (swap! registry assoc (str (:id job)) {:job job :run-fn run-fn :cancelled? false}) + (->> (store/persist! job) + (p/fmap (constantly job))))) + +(defn run-fn + [job-id] + (:run-fn (runtime job-id))) + +(defn cancelled? + [job-id] + (boolean (:cancelled? (runtime job-id)))) + +(defn terminal? + [job] + (contains? terminal-states (:state job))) + +(defn check-cancelled! + "Raises when the job has been cancelled. Called as each render's turn comes + up, so a cancellation stops the ones that have not started yet." + [job] + (when (cancelled? (:id job)) + (ex/raise :type :internal + :code :job-cancelled + :hint "export job was cancelled"))) + +(defn cancel-signal + "Int32Array over a SharedArrayBuffer, readable from a worker thread: 0 while + the job is live, 1 once it has been cancelled. Nil once the job has been + released -- writing the signal back would leave an entry for a settled job in + the registry that nothing would ever remove." + [job-id] + (let [k (str job-id)] + (when-let [rt (get @registry k)] + (or (:cancel-signal rt) + (let [signal (js/Int32Array. (js/SharedArrayBuffer. 4))] + (swap! registry update k (fn [rt] (some-> rt (assoc :cancel-signal signal)))) + (when (cancelled? job-id) + (js/Atomics.store signal 0 1)) + signal))))) + +(defn on-cancel + "Registers a callback used to abort the job's in-flight work (terminating a + render worker). A job fans out over several renders, so callbacks + accumulate. One registered for a job that already settled is dropped: keeping + it would revive that job's registry entry for good." + [job-id f] + (swap! registry update (str job-id) + (fn [rt] (some-> rt (update :cancel-fns (fnil conj []) f))))) + +(defn release! + "Drops the runtime entry once the job settled. The persisted record stays + until its TTL." + [job-id] + (swap! registry dissoc (str job-id))) + +(defn- live + "The job as the lifecycle last left it, or nil once it settled. Callers hold + the snapshot handed to them when their work started; writing that back would + resurrect a failed export as running and drop the error with it." + [job] + (when-let [current (:job (runtime (:id job)))] + (when-not (terminal? current) + current))) + +(defn- persist-and-publish! + [job] + (store-job! job) + (publish! job) + (store/persist! job)) + +(defn transition! + "Moves the job on. The first terminal state wins: anything arriving after it + is dropped, so a late failure cannot overwrite a cancellation, nor a straggler + overwrite either." + [job data] + (if-let [job (live job)] + (persist-and-publish! (merge job data)) + (p/resolved job))) + +(defn start! + [job] + (transition! job {:state "running" :started-at (now-ms)})) + +(defn progress! + "Reports `done` objects completed. Writes are throttled, so the caller need + not care how often it calls this." + [{:keys [id] :as job} done] + (if-let [job (live job)] + (let [k (str id) + now (now-ms) + last (:last-progress-ms (runtime id) 0) + job (assoc job :done done) + write? (>= (- now last) progress-throttle-ms)] + (store-job! job) + (if write? + (do + (swap! registry update k assoc :last-progress-ms now) + (persist-and-publish! job)) + (p/resolved job))) + (p/resolved job))) + +(defn complete! + [job {:keys [uri filename mtype size] :as _resource}] + (transition! job {:state "ended" + :ended-at (now-ms) + :done (:total job) + :resource-uri uri + :filename filename + :mtype mtype + :size size})) + +(defn fail! + [job cause] + (l/error :hint "export job failed" :job-id (str (:id job)) :cause cause) + (transition! job {:state "error" + :ended-at (now-ms) + :error (ex-message cause)})) + +(defn- cancel-local! + [job-id] + (let [k (str job-id) + rt (get @registry k) + job (:job rt)] + (if (or (nil? job) (terminal? job)) + (p/resolved job) + (do + (swap! registry update k (fn [rt] (some-> rt (assoc :cancelled? true)))) + (when-let [signal (:cancel-signal rt)] + (js/Atomics.store signal 0 1)) + ;; Recorded before the callbacks run, not after: one of them + ;; (`scheduler/drop-queued!`) releases the job, and `transition!` on a + ;; released job is a no-op, so a queued job would keep claiming to be + ;; queued in the store and never publish its `cancelled` update. + (let [result (transition! job {:state "cancelled" :ended-at (now-ms)})] + (doseq [f (:cancel-fns rt)] + (try + (f) + (catch :default cause + (l/warn :hint "error on job cancel callback" :job-id k :cause cause)))) + result))))) + +(defn cancel! + "Cancels a job. One this process does not own is broadcast over the cancel + topic, so whoever runs it acts on it. Idempotent." + [job-id] + (if (some? (runtime job-id)) + (cancel-local! job-id) + (->> (fetch job-id) + (p/mcat (fn [job] + (cond + (nil? job) (p/resolved nil) + (terminal? job) (p/resolved job) + :else (p/do + (store/request-cancel! (:id job)) + job))))))) + +(defn- clean-abandoned! + "Marks every job left mid-flight by a previous process as cancelled. + + A queue and its running jobs live in the memory of the process that owns + them, so nothing in flight when it died can be resumed; without this the + record would keep claiming to be running until its TTL expires. + + NOTE: the store cannot tell whose jobs are whose, so with more than one + exporter behind a load balancer this would also cancel a sibling's running + jobs. Single-instance deployments only." + [] + (->> (store/fetch-all) + (p/mcat (fn [jobs] + (let [abandoned (remove terminal? jobs)] + (when (seq abandoned) + (l/warn :hint "cancelling jobs abandoned by a previous process" + :count (count abandoned))) + (->> abandoned + (map (fn [job] + (store/persist! (assoc job + :state "cancelled" + :interrupted true + :ended-at (now-ms))))) + (p/all))))) + (p/fmap (fn [result] (count result))) + (p/merr (fn [cause] + (l/warn :hint "unable to clean abandoned jobs" :cause cause) + (p/resolved 0))))) + +(defn init + [] + (store/on-cancel-request + (fn [job-id] + (when (some? (runtime job-id)) + (l/info :hint "remote cancel request" :job-id job-id) + (cancel-local! job-id)))) + (clean-abandoned!)) diff --git a/exporter/src/app/jobs/scheduler.cljs b/exporter/src/app/jobs/scheduler.cljs new file mode 100644 index 0000000000..efad44d99f --- /dev/null +++ b/exporter/src/app/jobs/scheduler.cljs @@ -0,0 +1,149 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.jobs.scheduler + "Admission control for export jobs. + + Limits concurrent jobs and rejects work rather than allowing an unbounded backlog. + Queue order is FIFO, except jobs whose profile is already at its cap are skipped, + as are headless jobs once every render worker is busy." + (:require + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.config :as cf] + [app.jobs :as jobs] + [app.jobs.utils :as job.utils] + [app.wasm.pool :as pool] + [promesa.core :as p])) + +(l/set-level! :debug) + +(defonce ^:private state + (atom {:running {} ;; job-id -> {:profile-id :headless?} + :queue []})) ;; vector of {:job :resolve :reject} + +(defn- max-concurrent [] (cf/get :exporter-max-concurrent-jobs 4)) +(defn- max-per-profile [] (cf/get :exporter-max-jobs-per-profile 2)) +(defn- max-queued [] (cf/get :exporter-queue-max 64)) + +(defn- headless? + [job] + (= "wasm" (:backend job))) + +(defn- running-for + [{:keys [running]} profile-id] + (count (filter #(= profile-id (:profile-id %)) (vals running)))) + +(defn- running-headless + [{:keys [running]}] + (count (filter :headless? (vals running)))) + +(defn- eligible? + [state job] + (and (< (count (:running state)) (max-concurrent)) + (< (running-for state (:profile-id job)) (max-per-profile)) + ;; A headless job holds one render worker for its whole run, so admitting + ;; more of them than there are workers would only move the wait inside + ;; the pool, with the job already reporting itself as running. + (or (not (headless? job)) + (< (running-headless state) (pool/capacity))))) + +(declare ^:private pump!) + +(defn- finish! + [job-id] + (swap! state update :running dissoc (str job-id)) + (jobs/release! job-id) + (job.utils/release! job-id) + (pump!)) + +(defn- execute! + [{:keys [id profile-id] :as job}] + (swap! state update :running assoc (str id) {:profile-id profile-id + :headless? (headless? job)}) + (if (jobs/cancelled? id) + (do (finish! id) + (p/resolved job)) + (let [run-fn (jobs/run-fn id)] + (->> (p/do (jobs/start! job)) + (p/mcat (fn [job] (p/do (run-fn job)))) + (p/fnly (fn [_ _] (finish! id))))))) + +(defn- drop-queued! + "Removes a queued job and settles its promise, freeing its queue slot on cancellation." + [job-id] + (let [entry (volatile! nil)] + (swap! state (fn [state] + (let [queue (:queue state) + idx (->> (map-indexed vector queue) + (some (fn [[idx entry]] + (when (= (str job-id) (str (-> entry :job :id))) + idx))))] + (if idx + (do (vreset! entry (nth queue idx)) + (assoc state :queue (into (subvec queue 0 idx) (subvec queue (inc idx))))) + state)))) + (when-let [{:keys [resolve]} @entry] + (jobs/release! job-id) + (job.utils/release! job-id) + (resolve nil)))) + +(defn- take-eligible + "Pops the first queued entry that can run now, or nil." + [state] + (let [queue (:queue state) + idx (->> (map-indexed vector queue) + (some (fn [[idx entry]] + (when (eligible? state (:job entry)) + idx))))] + (when idx + [(assoc state :queue (into (subvec queue 0 idx) (subvec queue (inc idx)))) + (nth queue idx)]))) + +(defn- pump! + [] + (loop [] + (let [entry (volatile! nil)] + (swap! state (fn [state] + (if-let [[next-state next-entry] (take-eligible state)] + (do (vreset! entry next-entry) next-state) + (do (vreset! entry nil) state)))) + (when-let [{:keys [job resolve reject]} @entry] + (-> (execute! job) + (p/then resolve) + (p/catch reject)) + (recur))))) + +(defn submit! + "Registers `job` for execution. Returns a promise of the job's result, which + resolves when the export actually finishes; callers that only need the handle + can ignore it. Raises when the exporter is saturated." + [job] + (let [resolve* (volatile! nil) + reject* (volatile! nil) + pending (p/create (fn [resolve reject] + (vreset! resolve* resolve) + (vreset! reject* reject)))] + (if (eligible? @state job) + (-> (execute! job) + (p/then @resolve*) + (p/catch @reject*)) + + (let [queued (volatile! false)] + (swap! state (fn [state] + (if (< (count (:queue state)) (max-queued)) + (do (vreset! queued true) + (update state :queue conj {:job job + :resolve @resolve* + :reject @reject*})) + (do (vreset! queued false) state)))) + (when-not @queued + (ex/raise :type :validation + :code :queue-full + :hint "too many queued export jobs")) + (jobs/on-cancel (:id job) (fn [] (drop-queued! (:id job)))) + (l/dbg :hint "export job queued" :job-id (str (:id job))))) + pending)) diff --git a/exporter/src/app/jobs/store.cljs b/exporter/src/app/jobs/store.cljs new file mode 100644 index 0000000000..502c2d0676 --- /dev/null +++ b/exporter/src/app/jobs/store.cljs @@ -0,0 +1,87 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.jobs.store + "Redis persistence for export jobs. + + Stores each job as a single blob with a TTL matching the exported file, + so records expire with their files. + + Reads use Redis. Cancellation requires the process running the job." + (:require + [app.common.logging :as l] + [app.common.transit :as t] + [app.config :as cf] + [app.redis :as redis] + [promesa.core :as p])) + +(def ^:private cancel-topic (redis/->key "job-cancel")) + +(defn- job-key + [job-id] + (redis/->key "job." job-id)) + +(defn- ttl + [] + (cf/get :exporter-job-ttl 3600)) + +(defn persist! + "Writes the job record and refreshes its TTL. + + If the write fails, log it and continue. The export still runs + and publishes websocket updates, but the job can't be fetched + afterward (fetch returns nil, REST returns 404)." + [{:keys [id state] :as job}] + (let [jkey (job-key id)] + (->> (p/do + (redis/hset! jkey {:data (t/encode-str job)}) + (redis/expire! jkey (ttl)) + job) + (p/merr (fn [cause] + (if (= :redis-not-available (:code (ex-data cause))) + (l/warn :hint "job record not persisted, no redis connection" + :job-id (str id) :state state) + (l/error :hint "unable to persist job record" + :job-id (str id) :state state :cause cause)) + (p/resolved job)))))) + +(defn fetch + "The job record, or nil when unknown or expired." + [job-id] + (->> (redis/hgetall (job-key job-id)) + (p/fmap (fn [data] + (when-let [blob (get data "data")] + (try + (t/decode-str blob) + (catch :default cause + (l/warn :hint "unable to decode job record" :job-id (str job-id) :cause cause) + nil))))))) + +(defn fetch-all + [] + (->> (redis/scan (redis/->key "job.*")) + (p/mcat (fn [keys] + (->> (map (fn [k] + (->> (redis/hgetall k) + (p/fmap (fn [data] + (when-let [blob (get data "data")] + (try + (t/decode-str blob) + (catch :default _ nil))))))) + keys) + (p/all)))) + (p/fmap (fn [jobs] (vec (remove nil? jobs)))))) + +(defn request-cancel! + "Asks every exporter to cancel `job-id`. Only the one running it will act." + [job-id] + (redis/pub! cancel-topic (str job-id))) + +(defn on-cancel-request + "Registers `handler` (fn of the job-id string) for cancel requests. Returns an + unsubscribe fn." + [handler] + (redis/sub! cancel-topic handler)) diff --git a/exporter/src/app/jobs/utils.cljs b/exporter/src/app/jobs/utils.cljs new file mode 100644 index 0000000000..37480bb706 --- /dev/null +++ b/exporter/src/app/jobs/utils.cljs @@ -0,0 +1,86 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.jobs.utils + "Temp file ownership for export jobs. + + Temp files used to be cleaned only by the per-file timer in `app.util.shell`, + an hour after creation and lost entirely on restart. Here each job owns the + paths it creates, so they are dropped as soon as it settles and whatever a + crash left behind is cleaned at boot." + (:require + ["node:fs/promises" :as fsp] + ["node:path" :as path] + [app.common.logging :as l] + [app.config :as cf] + [app.util.shell :as sh] + [cuerdas.core :as str] + [promesa.core :as p])) + +(def ^:private managed-prefix "penpot.") + +(defonce ^:private tracked (atom {})) + +(defn track! + "Registers `path` as owned by `job-id`, so it is removed when the job settles." + [job-id path] + (when (and job-id path) + (swap! tracked update (str job-id) (fnil conj #{}) path)) + path) + +(defn- remove-path! + [path] + (->> (p/do (fsp/rm path #js {:recursive true :force true})) + (p/merr (fn [cause] + (l/warn :hint "unable to remove job temp file" :path path :cause cause) + (p/resolved nil))))) + +(defn release! + "Removes every file the job owns. Called once the job reached a terminal + state and its result has already been uploaded, so nothing else reads them." + [job-id] + (let [k (str job-id) + paths (get @tracked k)] + (swap! tracked dissoc k) + (if (seq paths) + (->> (map remove-path! paths) + (p/all) + (p/fmap (fn [_] + (l/dbg :hint "released job temp files" :job-id k :count (count paths)) + nil))) + (p/resolved nil)))) + +(defn- clean! + "Removes managed temp files older than the job TTL. They can only be leftovers + of a previous process: every live one belongs to a job of this process." + [] + (let [max-age (* 1000 (cf/get :exporter-job-ttl 3600)) + now (js/Date.now)] + (->> (p/do (fsp/readdir sh/tmpdir)) + (p/mcat (fn [entries] + (->> (filter #(str/starts-with? % managed-prefix) entries) + (map (fn [entry] + (let [fpath (path/join sh/tmpdir entry)] + (->> (p/do (fsp/stat fpath)) + (p/mcat (fn [^js stat] + (if (> (- now (inst-ms (.-mtime stat))) max-age) + (->> (remove-path! fpath) + (p/fmap (constantly 1))) + (p/resolved 0)))) + (p/merr (fn [_] (p/resolved 0))))))) + (p/all)))) + (p/fmap (fn [results] + (let [removed (reduce + 0 results)] + (when (pos? removed) + (l/info :hint "removed orphaned export temp files" :count removed)) + removed))) + (p/merr (fn [cause] + (l/warn :hint "temp file cleanup failed" :cause cause) + (p/resolved 0)))))) + +(defn init + [] + (clean!)) diff --git a/exporter/src/app/redis.cljs b/exporter/src/app/redis.cljs index 68c74a04c8..8fca0c335c 100644 --- a/exporter/src/app/redis.cljs +++ b/exporter/src/app/redis.cljs @@ -2,53 +2,176 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.redis (:require ["ioredis" :as redis] [app.common.data.macros :as dm] + [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.transit :as t] - [app.config :as cf])) + [app.config :as cf] + [promesa.core :as p])) (l/set-level! :trace) (def client (atom nil)) +;; A connection in subscriber mode rejects every other command, so the +;; subscriptions need a connection of their own. +(def ^:private subscriber (atom nil)) + +(def ^:private subscriptions (atom {})) + (defn- create-client - [uri] + [uri role] (let [^js client (new redis/default uri)] (.on client "connect" - (fn [] (l/info :hint "redis connection established" :uri uri))) + (fn [] (l/info :hint "redis connection established" :uri uri :role role))) (.on client "error" - (fn [cause] (l/error :hint "error on redis connection" :cause cause))) + (fn [cause] (l/error :hint "error on redis connection" :role role :cause cause))) (.on client "close" - (fn [] (l/warn :hint "connection closed"))) + (fn [] (l/warn :hint "connection closed" :role role))) (.on client "reconnect" - (fn [ms] (l/warn :hint "reconnecting to redis" :ms ms))) + (fn [ms] (l/warn :hint "reconnecting to redis" :role role :ms ms))) (.on client "end" - (fn [] (l/warn :hint "client ended, no more connections will be attempted"))) + (fn [] (l/warn :hint "client ended, no more connections will be attempted" :role role))) client)) +(defn- dispatch-message + [topic payload] + (doseq [handler (get @subscriptions topic)] + (try + (handler payload) + (catch :default cause + (l/error :hint "error on redis subscription handler" :topic topic :cause cause))))) + (defn init [] - (swap! client (fn [prev] - (when prev (.disconnect ^js prev)) - (create-client (cf/get :redis-uri))))) - + (let [uri (cf/get :redis-uri)] + (swap! client (fn [prev] + (when prev (.disconnect ^js prev)) + (create-client uri "commands"))) + (swap! subscriber (fn [prev] + (when prev (.disconnect ^js prev)) + (let [^js conn (create-client uri "subscriber")] + (.on conn "message" (fn [topic payload] (dispatch-message topic payload))) + ;; Reinstate subscriptions after a reconnection. + (.on conn "connect" + (fn [] + (doseq [topic (keys @subscriptions)] + (.subscribe conn topic)))) + conn))))) (defn stop [] + (reset! subscriptions {}) + (swap! subscriber (fn [conn] + (when conn (.quit ^js conn)) + nil)) (swap! client (fn [client] (when client (.quit ^js client)) nil))) (def ^:private tenant (cf/get :tenant)) +(defn ->tenant-key + "Namespaces `parts` under the tenant, the prefix the backend msgbus uses." + [& parts] + (dm/str tenant "." (apply str parts))) + +(defn ->key + "Namespaces `parts` under the exporter, inside the tenant." + [& parts] + (dm/str "penpot.exporter." tenant "." (apply str parts))) + (defn pub! + "Publishes on `topic`, which must already be namespaced." [topic payload] - (let [payload (if (map? payload) (t/encode-str payload) payload) - topic (dm/str tenant "." topic)] + (let [payload (if (map? payload) (t/encode-str payload) payload)] (when-let [client @client] (.publish ^js client topic payload)))) + +(defn sub! + "Subscribes `handler` (fn of the raw payload string) to `topic`, which must + already be namespaced. Returns a 0-arg fn that removes this handler." + [topic handler] + (swap! subscriptions update topic (fnil conj []) handler) + (when-let [conn @subscriber] + (.subscribe ^js conn topic)) + (fn [] + (swap! subscriptions update topic (fn [handlers] (vec (remove #(= % handler) handlers)))))) + +(defn- with-client + "Runs `f` against the command connection. Rejects when there is no connection + or the command fails: whether a failure is survivable depends on what the + caller was doing, and only the caller knows." + [f] + (if-let [client @client] + (p/do (f client)) + (p/rejected (ex/error :type :internal + :code :redis-not-available + :hint "no redis connection")))) + +(defn- with-client-lenient + "For reads, where an unreachable redis is reported as \"nothing there\"." + [f] + (->> (with-client f) + (p/merr (fn [cause] + (l/warn :hint "redis command failed" :cause cause) + (p/resolved nil))))) + +(defn hset! + "Writes `data` (a map of string/keyword -> value) as a hash. Nil values are + dropped, since redis has no null." + [k data] + (let [obj (reduce-kv (fn [obj field value] + (if (some? value) + (doto obj (unchecked-set (name field) (str value))) + obj)) + #js {} + data)] + (if (zero? (alength (js/Object.keys obj))) + (p/resolved nil) + (with-client (fn [^js client] (.hset client k obj)))))) + +(defn hgetall + "Returns the hash as a map of string keys, or nil when it does not exist." + [k] + (->> (with-client-lenient (fn [^js client] (.hgetall client k))) + (p/fmap (fn [result] + (when (and result (pos? (alength (js/Object.keys result)))) + (persistent! + (reduce (fn [res field] + (assoc! res field (unchecked-get result field))) + (transient {}) + (js/Object.keys result)))))))) + +(defn expire! + [k seconds] + (with-client (fn [^js client] (.expire client k seconds)))) + +(defn del! + [k] + (with-client (fn [^js client] (.del client k)))) + +(defn scan + "Every key matching `pattern`, walked in cursor batches so a large keyspace is + never blocked the way `KEYS` would block it. + + Batches are accumulated in memory rather than consumed as a stream, which a + promise-returning fn cannot express. Fine for the job keyspace, but reading + redis wants a streaming or reactive interface before it is used for more." + [pattern] + (letfn [(step [cursor found] + (->> (with-client-lenient (fn [^js client] (.scan client cursor "MATCH" pattern "COUNT" 200))) + (p/mcat (fn [result] + (if (nil? result) + (p/resolved found) + (let [next-cursor (aget result 0) + found (into found (aget result 1))] + (if (= "0" next-cursor) + (p/resolved found) + (step next-cursor found))))))))] + (step "0" []))) diff --git a/exporter/src/app/renderer.cljs b/exporter/src/app/renderer.cljs index 32555c6b19..320ffa8060 100644 --- a/exporter/src/app/renderer.cljs +++ b/exporter/src/app/renderer.cljs @@ -2,15 +2,18 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.renderer "Common renderer interface." (:require + [app.common.logging :as l] [app.common.spec :as us] + [app.config :as cf] [app.renderer.bitmap :as rb] [app.renderer.pdf :as rp] [app.renderer.svg :as rs] + [app.renderer.wasm :as rw] [cljs.spec.alpha :as s])) (s/def ::name ::us/string) @@ -23,6 +26,7 @@ (s/def ::token ::us/string) (s/def ::filename ::us/string) (s/def ::is-wasm ::us/boolean) +(s/def ::job-id ::us/uuid) (s/def ::object (s/keys :req-un [::id ::name ::suffix ::filename] @@ -33,16 +37,45 @@ (s/def ::render-params (s/keys :req-un [::file-id ::page-id ::scale ::token ::type ::objects] - :opt-un [::is-wasm])) + :opt-un [::is-wasm ::job-id])) + +(defn headless? + "Whether `params` renders with render-wasm rather than a browser." + [{:keys [is-wasm]}] + (and is-wasm (contains? cf/flags :wasm-export))) (defn render - [{:keys [type] :as params} on-object] + [{:keys [type is-wasm] :as params} on-object] (us/verify ::render-params params) (us/verify fn? on-object) - (case type - :png (rb/render params on-object) - :jpeg (rb/render params on-object) - :webp (rb/render params on-object) - :pdf (rp/render params on-object) - :svg (rs/render params on-object))) + (let [headless? (headless? params)] + (when is-wasm + (l/info :hint "render" + :type type + :wasm-export (contains? cf/flags :wasm-export) + :backend (if headless? "wasm" "browser"))) + (if headless? + (rw/render params on-object) + (case type + :png (rb/render params on-object) + :jpeg (rb/render params on-object) + :webp (rb/render params on-object) + :pdf (rp/render params on-object) + :svg (rs/render params on-object))))) + +(defn with-scope + "Runs `f`, a fn of a render fn with the same signature as `render`. Exports + that render headless share one worker for the whole call instead of acquiring + one per render; the browser backend keeps rendering them in parallel." + [exports f] + (if (some headless? exports) + (rw/with-scope (:job-id (first exports)) + (fn [render-leased] + (f (fn [params on-object] + (us/verify ::render-params params) + (us/verify fn? on-object) + (if (headless? params) + (render-leased params on-object) + (render params on-object)))))) + (f render))) diff --git a/exporter/src/app/renderer/bitmap.cljs b/exporter/src/app/renderer/bitmap.cljs index c2720eb025..ceefc75df5 100644 --- a/exporter/src/app/renderer/bitmap.cljs +++ b/exporter/src/app/renderer/bitmap.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.renderer.bitmap "A bitmap renderer." @@ -17,7 +17,7 @@ [promesa.core :as p])) (defn render - [{:keys [file-id page-id share-id token scale type objects skip-children is-wasm] :as params} on-object] + [{:keys [file-id page-id share-id token scale type objects skip-children] :as params} on-object] (letfn [(prepare-options [uri] #js {:screen #js {:width bw/default-viewport-width :height bw/default-viewport-height} @@ -25,7 +25,7 @@ :height bw/default-viewport-height} :locale "en-US" :storageState #js {:cookies (bw/create-cookies uri {:token token})} - :deviceScaleFactor (if is-wasm 1 scale) ;; wasm won't use deviceScaleFactor + :deviceScaleFactor scale :userAgent bw/default-user-agent}) (render-object [page {:keys [id] :as object}] @@ -38,7 +38,7 @@ :webp (p/let [png-path (sh/tempfile :prefix "penpot.tmp.bitmap." :suffix ".png")] ;; playwright only supports jpg and png, we need to convert it afterwards (bw/screenshot node {:omit-background? true :type :png :path png-path}) - (sh/run-cmd! (str "convert " png-path " -quality 100 WEBP:" path)))) + (sh/run-cmd! "convert" png-path "-quality" "100" (str "WEBP:" path)))) (on-object (assoc object :path path)))) (render [uri page] @@ -59,9 +59,7 @@ :share-id share-id :object-id (mapv :id objects) :route "objects" - :skip-children skip-children - :wasm (when is-wasm "true") - :scale scale} + :skip-children skip-children} uri (-> (cf/get-internal-uri) (u/ensure-path-slash) (u/join "render.html") diff --git a/exporter/src/app/renderer/pdf.cljs b/exporter/src/app/renderer/pdf.cljs index ba4118c1e8..5d983d80c0 100644 --- a/exporter/src/app/renderer/pdf.cljs +++ b/exporter/src/app/renderer/pdf.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.renderer.pdf "A pdf renderer." diff --git a/exporter/src/app/renderer/svg.cljs b/exporter/src/app/renderer/svg.cljs index c9fee2f764..fdd9b7c0de 100644 --- a/exporter/src/app/renderer/svg.cljs +++ b/exporter/src/app/renderer/svg.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.renderer.svg (:require @@ -10,9 +10,12 @@ ["xml-js" :as xml] [app.browser :as bw] [app.common.data :as d] + [app.common.exceptions :as ex] [app.common.logging :as l] + [app.common.types.color :as ctc] [app.common.uri :as u] [app.config :as cf] + [app.renderer.svg-gradient :as svg-gradient] [app.util.mime :as mime] [app.util.shell :as sh] [clojure.walk :as walk] @@ -125,19 +128,23 @@ (letfn [(convert-to-ppm [pngpath] (let [ppmpath (str/concat pngpath "origin.ppm")] (l/trace :fn :convert-to-ppm :path ppmpath) - (-> (sh/run-cmd! (str "convert " pngpath " " ppmpath)) + (-> (sh/run-cmd! "convert" pngpath ppmpath) (p/then (constantly ppmpath))))) (trace-color-mask [pbmpath] (l/trace :fn :trace-color-mask :pbmpath pbmpath) (let [svgpath (str/concat pbmpath ".svg")] - (-> (sh/run-cmd! (str "potrace --flat -b svg " pbmpath " -o " svgpath)) + (-> (sh/run-cmd! "potrace" "--flat" "-b" "svg" pbmpath "-o" svgpath) (p/then (constantly svgpath))))) (generate-color-layer [ppmpath color] + (when-not (ctc/hex-color-string? color) + (ex/raise :type :validation + :code :invalid-color + :hint (str "invalid hex color: " color))) (l/trace :fn :generate-color-layer :ppmpath ppmpath :color color) (let [pbmpath (str/concat ppmpath ".mask-" (subs color 1) ".pbm")] - (-> (sh/run-cmd! (str/format "ppmcolormask \"%s\" %s" color ppmpath)) + (-> (sh/run-cmd! "ppmcolormask" color ppmpath) (p/then (fn [stdout] (-> (sh/write-file! pbmpath stdout) (p/then (constantly pbmpath))))) @@ -166,33 +173,11 @@ :else (update node "attributes" assoc "fill" color)))) - (get-stops [data] - (->> (get-in data ["gradient" "stops"]) - (mapv (fn [stop-data] - {"type" "element" - "name" "stop" - "attributes" {"offset" (get stop-data "offset") - "stop-color" (get stop-data "color") - "stop-opacity" (get stop-data "opacity")}})))) - - (data->gradient-def [id [color data]] - (let [id (str "gradient-" id "-" (subs color 1))] - (if (= type "linear") - {"type" "element" - "name" "linearGradient" - "attributes" {"id" id "x1" "0.5" "y1" "1" "x2" "0.5" "y2" "0"} - "elements" (get-stops data)} - - {"type" "element" - "name" "radialGradient" - "attributes" {"id" id "cx" "0.5" "cy" "0.5" "r" "0.5"} - "elements" (get-stops data)}))) - (get-gradients [id mapping] (->> mapping (filter (fn [[_color data]] (= (get data "type") "gradient"))) - (mapv (partial data->gradient-def id)))) + (mapv (partial svg-gradient/data->gradient-def id)))) (join-color-layers [{:keys [id x y width height mapping] :as node} layers] (l/trace :fn :join-color-layers :mapping mapping) @@ -369,4 +354,3 @@ (assoc :query (u/map->query-string params)))] (bw/exec! (prepare-options uri) (partial render uri))))) - diff --git a/exporter/src/app/renderer/svg_gradient.cljs b/exporter/src/app/renderer/svg_gradient.cljs new file mode 100644 index 0000000000..897ed80304 --- /dev/null +++ b/exporter/src/app/renderer/svg_gradient.cljs @@ -0,0 +1,32 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.renderer.svg-gradient) + +(defn- get-stops + [data] + (->> (get-in data ["gradient" "stops"]) + (mapv (fn [stop-data] + {"type" "element" + "name" "stop" + "attributes" {"offset" (get stop-data "offset") + "stop-color" (get stop-data "color") + "stop-opacity" (get stop-data "opacity")}})))) + +(defn data->gradient-def + [id [color data]] + (let [id (str "gradient-" id "-" (subs color 1)) + gradient-type (get-in data ["gradient" "type"])] + (if (= gradient-type "linear") + {"type" "element" + "name" "linearGradient" + "attributes" {"id" id "x1" "0.5" "y1" "1" "x2" "0.5" "y2" "0"} + "elements" (get-stops data)} + + {"type" "element" + "name" "radialGradient" + "attributes" {"id" id "cx" "0.5" "cy" "0.5" "r" "0.5"} + "elements" (get-stops data)}))) diff --git a/exporter/src/app/renderer/wasm.cljs b/exporter/src/app/renderer/wasm.cljs new file mode 100644 index 0000000000..a284e35269 --- /dev/null +++ b/exporter/src/app/renderer/wasm.cljs @@ -0,0 +1,50 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.renderer.wasm + "Main-thread side of the headless renderer. + + Renders run on pooled workers because Skia calls are synchronous and would block + the HTTP server and other exports. Each job keeps one worker for all its renders, + sharing its caches and pool slot." + (:require + [app.jobs :as jobs] + [app.wasm.pool :as pool] + [promesa.core :as p])) + +(defn- serializer + "Chains thunks so a job's renders run one at a time on its worker. A failure + is isolated: it doesn't break the chain for the next one." + [] + (let [queue (atom (p/resolved nil))] + (fn [thunk] + (let [result (p/handle @queue (fn [_ _] (thunk)))] + (reset! queue (p/handle result (fn [_ _] nil))) + result)))) + +(defn with-scope + "Runs `f`, a fn of a 2-arg render fn. Every render goes to the same worker, + one at a time, so the cancel check runs as each render's turn comes up." + [job-id f] + (pool/with-worker + (fn [worker] + (let [chain (serializer) + live (volatile! worker) + signal (when job-id (jobs/cancel-signal job-id)) + opts {:cancel-buffer (some-> signal (.-buffer)) + :cancelled? (when job-id #(jobs/cancelled? job-id))}] + (when job-id + ;; Between objects the worker sees the flag; inside a render only + ;; terminating the thread stops it. Cleared on the way out so a later + ;; cancel cannot terminate a worker that is by then somebody else's. + (jobs/on-cancel job-id (fn [] (pool/terminate! @live)))) + (->> (p/do (f (fn [params on-object] + (chain #(pool/render-on worker params on-object opts))))) + (p/fnly (fn [_ _] (vreset! live nil)))))))) + +(defn render + [params on-object] + (with-scope (:job-id params) (fn [render*] (render* params on-object)))) diff --git a/exporter/src/app/router.cljs b/exporter/src/app/router.cljs new file mode 100644 index 0000000000..991ab60a24 --- /dev/null +++ b/exporter/src/app/router.cljs @@ -0,0 +1,61 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.router + "Method + path dispatch. + + Requests arrive with whatever prefix the proxy in front uses (`/api/export` + in devenv, `/` when talking to the process directly), so routes are matched + on the remainder after that prefix." + (:require + [app.common.exceptions :as ex] + [app.handlers.jobs :as jobs.handlers] + [cuerdas.core :as str])) + +(def ^:private mount-point "/api/export") + +(defn- route-path + [path] + (let [path (or path "/") + path (if (str/starts-with? path mount-point) + (subs path (count mount-point)) + path) + path (str/rtrim path "/")] + (if (str/empty? path) "/" path))) + +(defn- job-id + [path prefix] + (let [id (subs path (count prefix))] + (when-not (or (str/empty? id) (str/includes? id "/")) + id))) + +(defn create + "Builds the request handler. `legacy-handler` serves the original + `POST /api/export` command multiplex." + [legacy-handler] + (fn [{:keys [:request/method :request/path] :as exchange}] + (let [path (route-path path)] + (cond + (and (= "post" method) (= "/" path)) + (legacy-handler exchange) + + (and (= "post" method) (= "/jobs" path)) + (jobs.handlers/create exchange) + + (and (= "get" method) (str/starts-with? path "/jobs/")) + (if-let [id (job-id path "/jobs/")] + (jobs.handlers/fetch exchange id) + (ex/raise :type :not-found :code :object-not-found :hint "unknown route")) + + (and (= "delete" method) (str/starts-with? path "/jobs/")) + (if-let [id (job-id path "/jobs/")] + (jobs.handlers/cancel exchange id) + (ex/raise :type :not-found :code :object-not-found :hint "unknown route")) + + :else + (ex/raise :type :not-found + :code :route-not-found + :hint (str "no route for " method " " path)))))) diff --git a/exporter/src/app/util/mime.cljs b/exporter/src/app/util/mime.cljs index bd8b885a2c..3f6ce62fbe 100644 --- a/exporter/src/app/util/mime.cljs +++ b/exporter/src/app/util/mime.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.mime "Mimetype and file extension helpers." diff --git a/exporter/src/app/util/object.cljs b/exporter/src/app/util/object.cljs index bf38ec4475..8c22bcfe92 100644 --- a/exporter/src/app/util/object.cljs +++ b/exporter/src/app/util/object.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.object "A collection of helpers for work with javascript objects." diff --git a/exporter/src/app/util/shell.cljs b/exporter/src/app/util/shell.cljs index 60dc1bd6b8..929924899b 100644 --- a/exporter/src/app/util/shell.cljs +++ b/exporter/src/app/util/shell.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.shell "Shell & FS utilities." @@ -94,14 +94,14 @@ (.readFile fs/promises fpath)) (defn run-cmd! - [cmd] + [cmd & args] (p/create (fn [resolve reject] - (l/trace :fn :run-cmd :cmd cmd) - (proc/exec cmd #js {:encoding "buffer"} - (fn [error stdout _stderr] - ;; (l/trace :fn :run-cmd :stdout stdout) - (if error - (reject error) - (resolve stdout))))))) + (l/trace :fn :run-cmd :cmd cmd :args args) + (proc/execFile cmd (clj->js args) #js {:encoding "buffer"} + (fn [error stdout _stderr] + ;; (l/trace :fn :run-cmd :stdout stdout) + (if error + (reject error) + (resolve stdout))))))) diff --git a/exporter/src/app/util/transit.cljs b/exporter/src/app/util/transit.cljs index 97cc02ca4e..859606e3ca 100644 --- a/exporter/src/app/util/transit.cljs +++ b/exporter/src/app/util/transit.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.transit (:require diff --git a/exporter/src/app/wasm.cljs b/exporter/src/app/wasm.cljs new file mode 100644 index 0000000000..cd6cbc09ea --- /dev/null +++ b/exporter/src/app/wasm.cljs @@ -0,0 +1,287 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.wasm + "Headless driver for the render-wasm module under Node: the GPU-free + counterpart of `app.render-wasm.api`. Loads the emscripten artifact, boots it + via `init_headless`, and exposes font provisioning + shape rendering. + + Serialization is reused from the portable render-wasm leaves, so this + namespace owns only the Node runtime and the headless render calls. + + Requires render-wasm built with `-sENVIRONMENT=web,node`." + (:require + ["node:fs" :as fs] + ["node:path" :as path] + [app.common.data :as d] + [app.common.logging :as l] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.wasm :as wasm] + [app.common.uuid :as uuid] + ;; Required for side effects: binds the generated enums. + [app.wasm.enums] + [promesa.core :as p] + [shadow.esm :refer [dynamic-import]])) + +(def ^:private default-viewport-width 1920) +(def ^:private default-viewport-height 1080) + +;; render_shape_raster / render_shape_pixels result header: [len u32][w u32][h u32]. +(def ^:private RASTER-HEADER-BYTES 12) +;; render_shape_pdf / render_shape_svg result header: [len u32] only. +(def ^:private LEN-HEADER-BYTES 4) +;; get_fonts_for_shape entry: [uuid 16 bytes][weight u32][style u32]. +(def ^:private FONT-ENTRY-BYTES 24) + +(def artifact-dir + "Built render-wasm artifact, relative to the process working directory. Same + path in devenv and inside the bundle, so it is a constant." + "resources/wasm") + +(def image-cache-size + "Byte budget the image store is trimmed to between requests." + (* 256 1024 1024)) + +(defn- read-result-bytes + "Reads `len` bytes from the WASM heap starting at `offset`, copying them out + (via `.slice`) before the buffer is freed." + [offset len] + (.slice (mem/get-heap-u8) offset (+ offset len))) + +;; --- MODULE LIFECYCLE + +(defn init! + "Loads the render-wasm artifact under Node and boots it headless. Sets the + shared `wasm/internal-module` so the portable serialization leaves work. + Idempotent-ish: callers should hold the returned module." + ([] (init! default-viewport-width default-viewport-height)) + ([width height] + (let [dir artifact-dir + js-path (path/resolve dir "render-wasm.js") + wasm-path (path/resolve dir "render-wasm.wasm") + wasm-bytes (fs/readFileSync wasm-path)] + (l/info :hint "loading render-wasm (headless)" :js js-path) + ;; shadow-cljs :esm — use its dynamic-import helper (raw `js/import` + ;; compiles to an undefined `import$`). + (->> (dynamic-import (str "file://" js-path)) + (p/mcat + (fn [mod] + (let [factory (unchecked-get mod "default")] + (factory + #js {;; Bypass the web fetch loader: instantiate from local bytes. + :instantiateWasm + (fn [imports success] + (-> (js/WebAssembly.instantiate wasm-bytes imports) + (.then (fn [result] (success (.-instance result))))) + #js {}) + :locateFile (fn [p] (path/resolve dir p)) + :printErr (fn [s] (l/warn :wasm s))})))) + (p/fmap + (fn [module] + (set! wasm/internal-module module) + (h/call module "_init_headless" width height) + (set! wasm/context-initialized? true) + (l/info :hint "render-wasm headless module ready" :width width :height height) + module)))))) + +;; --- FONT PROVISIONING (on demand, mirrors the browser) + +(defn fonts-for-shape + "Returns the distinct font families needed to render the subtree rooted at + `shape-id` as a vector of {:id <uuid-u32x4> :weight :style}. Equivalent to + the browser's `get-content-fonts`, but read from the loaded WASM tree." + [shape-id] + (let [module wasm/internal-module + buf (uuid/get-u32 shape-id) ;; resolved from app.render-wasm leaves + offset (h/call module "_get_fonts_for_shape" + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)) + heap32 (mem/get-heap-u32) + n (aget heap32 (mem/->offset-32 offset)) + ;; `vec` must stay eager: it reads the result buffer, and the + ;; `mem/free` below invalidates these offsets. + entries (vec + (for [i (range n)] + (let [base (+ offset 4 (* i FONT-ENTRY-BYTES)) + u32 (fn [o] (aget heap32 (mem/->offset-32 (+ base o))))] + {:id #js [(u32 0) (u32 4) (u32 8) (u32 12)] + :weight (u32 16) + :style (u32 20)})))] + (mem/free) + entries)) + +(defn- font-key + "Value key for a family map. Its `:id` is a JS array, so the map itself can't + be compared by value." + [{:keys [id weight style]}] + [(aget id 0) (aget id 1) (aget id 2) (aget id 3) weight style]) + +(defn fonts-for-shapes + "Distinct font families needed by every subtree in `shape-ids`. Objects in one + export overwhelmingly share families, so deduping here means one download and + one `_store_font` per family rather than one per object." + [shape-ids] + (into [] (comp (mapcat fonts-for-shape) + (d/distinct-xf font-key)) + shape-ids)) + +(defn store-font! + "Uploads one font's TTF bytes into the WASM font store, keyed by the family + (uuid quartet + weight + style). `font-bytes` is a Uint8Array/Buffer. + + Does NOT call `mem/free` — `store_font` (and likewise `store_image` below) + releases the global buffer itself on the Rust side. Freeing again here would + drop a buffer a later writer already owns." + [{:keys [id weight style emoji? fallback?]} font-bytes] + (let [module wasm/internal-module + size (.-byteLength font-bytes) + ptr (h/call module "_alloc_bytes" size) + heap (mem/get-heap-u8)] + (.set heap (js/Uint8Array. font-bytes) ptr) + (h/call module "_store_font" + (aget id 0) (aget id 1) (aget id 2) (aget id 3) + weight style (boolean emoji?) (boolean fallback?)))) + +(defn store-font-url! + "Registers the public URL a font family was loaded from. The SVG export emits + one `@font-face` per family from these, and skips families without one, so + this must run for every family `store-font!` uploads. + + Does NOT call `mem/free`, for the same reason as `store-font!`." + [{:keys [id weight style]} url] + (let [bytes (js/Buffer.from url "utf-8") + ptr (mem/alloc (.-byteLength bytes))] + (mem/write-buffer ptr (mem/get-heap-u8) bytes) + (h/call wasm/internal-module "_store_font_url" + (aget id 0) (aget id 1) (aget id 2) (aget id 3) + weight style))) + +(defn clear-fonts! + "Resets the WASM font store. Must be called once per render request because + the shared module would otherwise accumulate fonts across requests." + [] + (h/call wasm/internal-module "_clear_fonts")) + +(defn update-text-layout! + "Recomputes a text shape's layout with the currently provisioned fonts. Text is + laid out at serialize time using the fallback font (real fonts aren't uploaded + yet), so this must run again after `provision-fonts!` or glyph metrics/line + breaks are wrong. + + Forced, because provisioning a font changes nothing `update_layout` keys on: + it early-returns while the content is unchanged and the layout still matches + its container, which is exactly the case here." + [shape-id] + (let [buf (uuid/get-u32 shape-id)] + (h/call wasm/internal-module "_force_update_shape_text_layout_for" + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)))) + +(defn image-cached? + "True when the module's image store already holds this image (full size). + The store is NOT reset between requests, so previously provisioned images + can be reused instead of refetched." + [image-id] + (let [buf (uuid/get-u32 image-id)] + (not (zero? (h/call wasm/internal-module "_is_image_cached" + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) + false))))) + +(defn store-image! + "Uploads one image's *encoded* bytes (PNG/JPEG — Skia decodes, no WebGL) into + the WASM image store via `_store_image`. Buffer layout matches the Rust reader: + [shape uuid 16][image uuid 16][is_thumbnail u32][encoded bytes]. Images are + keyed by image uuid, so the shape uuid is left zero. `image-bytes` is an + ArrayBuffer/Buffer/Uint8Array." + [image-id image-bytes] + (let [module wasm/internal-module + img-u8 (js/Uint8Array. image-bytes) + size (.-byteLength img-u8) + total (+ 36 size) + ptr (h/call module "_alloc_bytes" total) + heap (mem/get-heap-u8) + dview (js/DataView. (.-buffer heap)) + quart (uuid/get-u32 image-id)] + ;; shape uuid [0..16) = 0 (images are keyed by image uuid only) + (.setUint32 dview (+ ptr 0) 0 true) + (.setUint32 dview (+ ptr 4) 0 true) + (.setUint32 dview (+ ptr 8) 0 true) + (.setUint32 dview (+ ptr 12) 0 true) + ;; image uuid [16..32) — 4 LE u32 (matches common `buffer/write-uuid`, which + ;; the fill path uses, so it hashes to the same key the fill references) + (.setUint32 dview (+ ptr 16) (aget quart 0) true) + (.setUint32 dview (+ ptr 20) (aget quart 1) true) + (.setUint32 dview (+ ptr 24) (aget quart 2) true) + (.setUint32 dview (+ ptr 28) (aget quart 3) true) + ;; is_thumbnail [32..36) = 0 + (.setUint32 dview (+ ptr 32) 0 true) + ;; encoded bytes [36..) + (.set heap img-u8 (+ ptr 36)) + (h/call module "_store_image"))) + +(defn evict-images! + "Evicts least-recently-used images until the store retains at most `max-bytes` + bytes. Returns the number evicted." + [max-bytes] + (h/call wasm/internal-module "_evict_images_to_budget" max-bytes)) + +(defn provision-fonts! + "Resolves and uploads every font needed by `shape-ids`, each family fetched + once. `resolve-font` is an injected fn of the family map -> promise of TTF + bytes (or nil to skip); optional `font-url` is a fn of the family map -> the + public URL those bytes came from. This keeps the font *source* (gfonts proxy + / custom assets / backend) out of the driver." + [shape-ids resolve-font & {:keys [font-url]}] + (->> (fonts-for-shapes shape-ids) + (map (fn [family] + (->> (resolve-font family) + (p/fmap (fn [bytes] + (when bytes + (store-font! family bytes) + (when-let [url (when font-url (font-url family))] + (store-font-url! family url)))))))) + (p/all))) + +;; --- RENDER + +(defn- read-render-result + "Copies the encoded payload out of a `_render_shape_*` result buffer and frees + it. `header-bytes` is the size of the header preceding the payload." + [offset header-bytes] + (let [heap32 (mem/get-heap-u32) + len (aget heap32 (mem/->offset-32 offset)) + bytes (read-result-bytes (+ offset header-bytes) len)] + (mem/free) + bytes)) + +(defn render-shape-raster + "Renders the shape subtree to encoded image bytes (Uint8Array) on a CPU + surface. `format` is :png, :jpeg or :webp; jpeg is flattened onto white on + the Rust side, since it has no alpha channel." + [shape-id scale format] + (let [buf (uuid/get-u32 shape-id)] + (-> (h/call wasm/internal-module "_render_shape_raster" + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) + scale (sr/translate-raster-format format)) + (read-render-result RASTER-HEADER-BYTES)))) + +(defn render-shape-pdf + "Renders the shape subtree to PDF bytes (Uint8Array)." + [shape-id scale] + (let [buf (uuid/get-u32 shape-id)] + (-> (h/call wasm/internal-module "_render_shape_pdf" + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) + scale) + (read-render-result LEN-HEADER-BYTES)))) + +(defn render-shape-svg + "Renders the shape subtree to SVG markup bytes (Uint8Array)." + [shape-id scale] + (let [buf (uuid/get-u32 shape-id)] + (-> (h/call wasm/internal-module "_render_shape_svg" + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) + scale) + (read-render-result LEN-HEADER-BYTES)))) diff --git a/exporter/src/app/wasm/enums.cljs b/exporter/src/app/wasm/enums.cljs new file mode 100644 index 0000000000..5ecd5ebb43 --- /dev/null +++ b/exporter/src/app/wasm/enums.cljs @@ -0,0 +1,19 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.wasm.enums + "Binds this build's generated enums into the shared bridge. + + `shared.js` is emitted next to this file by `render-wasm/build export` and is + not committed. Requiring this namespace is what makes + `app.common.render-wasm.wasm/serializers` usable." + (:require + ["./shared.js" :as shared] + [app.common.render-wasm.wasm :as wasm]) + (:require-macros + [app.common.render-wasm.enums :as enums])) + +(wasm/init-serializers! (enums/serializers shared)) diff --git a/exporter/src/app/wasm/pool.cljs b/exporter/src/app/wasm/pool.cljs new file mode 100644 index 0000000000..64f8c9d05e --- /dev/null +++ b/exporter/src/app/wasm/pool.cljs @@ -0,0 +1,246 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.wasm.pool + "Pool of headless render workers. + + Mirrors `app.browser`: a `generic-pool` whose objects are `worker_threads` + instead of browsers, so acquisition and eviction behave the same way for both + render backends. A worker is expensive to build (it boots its own render-wasm + module), hence the pooling. + + Acquisition is not capped: the admission scheduler is the backpressure, and + the idle watchdog guarantees a wedged worker gives its slot back. + + Workers run the same bundle as the main thread; `app.core/start` branches on + `isMainThread`. Without the `wasm-export` flag no worker is spawned at all; + with it there is always at least one, since a headless render has nowhere + else to go." + (:require + ["generic-pool" :as gp] + ["node:path" :as path] + ["node:process" :as proc] + ["node:worker_threads" :as wt] + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.common.transit :as t] + [app.config :as cf] + [promesa.core :as p])) + +(l/set-level! :info) + +(defonce pool (atom nil)) +(defonce ^:private worker-id (atom 0)) + +(def ^:private ready-timeout-ms 60000) + +(defn- idle-timeout-ms + "How long a render may go silent before the worker is presumed wedged. Reset + on every message, so a long export keeps its worker as long as it keeps + reporting objects; only a thread stuck inside Skia, which reports nothing and + emits no `exit`, runs it out." + [] + (* 1000 (cf/get :wasm-worker-idle-timeout 300))) + +(defn- worker-script + [] + (path/resolve (aget (.-argv proc/default) 1))) + +(defn- create-worker + [] + (p/create + (fn [resolve reject] + (let [script (worker-script) + id (swap! worker-id inc) + worker (new wt/Worker script) + timer (js/setTimeout + (fn [] + (l/error :hint "render worker did not become ready" :worker-id id) + (.terminate ^js worker) + (reject (ex/error :type :internal + :code :worker-not-ready + :hint "render worker did not become ready"))) + ready-timeout-ms)] + + (unchecked-set worker "__id" id) + (unchecked-set worker "__alive" true) + + (.on ^js worker "error" + (fn [cause] + (l/error :hint "render worker error" :worker-id id :cause cause) + (unchecked-set worker "__alive" false) + ;; A worker that dies while booting has to fail its own creation; + ;; rejecting after `resolve` is a no-op, so this is safe for the + ;; errors that arrive once it is already in the pool. + (js/clearTimeout timer) + (reject cause))) + + (.on ^js worker "exit" + (fn [code] + (l/info :hint "render worker exited" :worker-id id :code code) + (unchecked-set worker "__alive" false))) + + ;; Not `.once`: a stray message before the handshake would consume the + ;; listener and leave the worker hanging until `ready-timeout-ms`. + (letfn [(on-ready [data] + (when (= "ready" (unchecked-get data "type")) + (js/clearTimeout timer) + (.off ^js worker "message" on-ready) + (l/info :origin "factory" :action "create" :worker-id id) + (resolve worker)))] + (.on ^js worker "message" on-ready)))))) + +(def ^:private worker-pool-factory + #js {:create create-worker + :destroy (fn [worker] + (l/info :origin "factory" :action "destroy" + :worker-id (unchecked-get worker "__id")) + (.terminate ^js worker)) + :validate (fn [worker] + (p/resolved (true? (unchecked-get worker "__alive"))))}) + +(defn capacity + "How many renders can run at once, and so how many headless jobs the + scheduler may admit. Zero exactly when headless export is off, which is also + when no job is headless, so a headless job always has a worker to wait for." + [] + (if (contains? cf/flags :wasm-export) + ;; Clamped rather than rejected: a bad value should not stop the exporter + ;; from booting, and a headless render has no other backend to fall back to. + (max 1 (cf/get :wasm-worker-pool-max 2)) + 0)) + +(defn init + [] + (let [configured (cf/get :wasm-worker-pool-max 2) + max-workers (capacity)] + (when (and (pos? max-workers) (not= configured max-workers)) + (l/warn :hint "wasm-worker-pool-max raised to the minimum of one" + :configured configured)) + (if (pos? max-workers) + (let [opts #js {:max max-workers + :min (min max-workers (cf/get :wasm-worker-pool-min 1)) + :testOnBorrow true + :evictionRunIntervalMillis 30000 + :numTestsPerEvictionRun 2 + :idleTimeoutMillis 300000}] + (l/info :hint "initializing render worker pool" :opts opts) + (reset! pool (gp/createPool worker-pool-factory opts))) + (l/info :hint "render worker pool disabled, wasm export is off")) + (p/resolved nil))) + +(defn stop + [] + (when-let [instance @pool] + (l/info :hint "finalizing render worker pool") + (reset! pool nil) + (p/do + (.drain ^js instance) + (.clear ^js instance)))) + +(defn- run-on-worker + "Settles when the worker reports the render finished, failed, or the thread + went away. That last case matters: a terminated worker (how a cancel stops a + render mid-Skia) emits `exit` and never `error`, and a promise left pending + there would keep its pool slot borrowed for the life of the process." + [^js worker params cancel-buffer on-object] + (p/create + (fn [resolve reject] + (let [timer (volatile! nil)] + (letfn [(disarm [] + (when-let [t @timer] + (js/clearTimeout t) + (vreset! timer nil))) + + (rearm [] + (disarm) + (vreset! timer (js/setTimeout + (fn [] + (l/error :hint "render worker went silent, terminating" + :worker-id (unchecked-get worker "__id")) + (cleanup) + ;; Terminating is what frees the pool slot: + ;; the `exit` it raises has no listener left. + (unchecked-set worker "__alive" false) + (.terminate ^js worker) + (reject (ex/error :type :internal + :code :render-timeout + :hint "render worker stopped responding"))) + (idle-timeout-ms)))) + + (cleanup [] + (disarm) + (.off worker "message" on-message) + (.off worker "error" on-error) + (.off worker "exit" on-exit)) + + (on-error [cause] + (cleanup) + (reject cause)) + + (on-exit [code] + (cleanup) + (reject (ex/error :type :internal + :code :worker-exited + :hint (str "render worker exited with code " code)))) + + (on-message [data] + (rearm) + (case (unchecked-get data "type") + ;; A failure while the main thread handles the object (moving + ;; the file, appending to the zip) has to end the render too, + ;; or nothing ever settles this promise. + "object" (try + (on-object (t/decode-str (unchecked-get data "payload"))) + (catch :default cause + (cleanup) + (reject cause))) + "done" (do (cleanup) (resolve nil)) + "error" (do (cleanup) + (reject (ex/error :type :internal + :code (or (some-> (unchecked-get data "code") keyword) + :wasm-render-error) + :hint (unchecked-get data "message")))) + nil))] + + (.on worker "message" on-message) + (.once worker "error" on-error) + (.once worker "exit" on-exit) + (rearm) + (.postMessage worker #js {:type "render" + :params (t/encode-str params) + :cancel cancel-buffer})))))) + +(defn with-worker + "Acquires one worker for the whole of `f`, a fn of that worker." + [f] + (let [instance @pool] + (->> (p/do (.acquire ^js instance)) + (p/mcat (fn [worker] + (->> (p/do (f worker)) + (p/fmap (fn [result] + (.release ^js instance worker) + result)) + (p/merr (fn [cause] + ;; The module may be aborted or mid-write, and + ;; a terminated worker cannot be reused. + (-> (p/do (.destroy ^js instance worker)) + (p/handle (fn [_ _] (p/rejected cause)))))))))))) + +(defn render-on + "Renders `params` on an already acquired worker." + [worker params on-object {:keys [cancel-buffer cancelled?]}] + (if (and cancelled? (cancelled?)) + (p/rejected (ex/error :type :internal + :code :job-cancelled + :hint "export job was cancelled")) + (run-on-worker worker params cancel-buffer on-object))) + +(defn terminate! + [^js worker] + (when worker + (unchecked-set worker "__alive" false) + (.terminate worker))) diff --git a/exporter/src/app/wasm/render.cljs b/exporter/src/app/wasm/render.cljs new file mode 100644 index 0000000000..6993c53bba --- /dev/null +++ b/exporter/src/app/wasm/render.cljs @@ -0,0 +1,497 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.wasm.render + "Headless render pipeline: renders exports with the render-wasm Skia pipeline, + with no browser and no WebGL. + + Per request: fetch scene (get-page RPC) -> serialize -> provision fonts and + images -> relayout text with the real fonts -> render each object. + + This runs inside a render worker (`app.wasm.worker`), one WASM design state + per worker, so the synchronous Skia calls never block the process that serves + HTTP. `app.renderer.wasm` is the main-thread side that drives it. + + Moved here verbatim from `app.renderer.wasm`; git reads it as a new file only + because that namespace still exists as the proxy. Reviewable as a rename: + `git show <base>:exporter/src/app/renderer/wasm.cljs | diff -u - <this file>`. + + Handles png/jpeg/webp (Skia encodes all three), pdf and svg." + (:require + ["node:fs" :as fs] + ["undici" :as http] + [app.common.data :as d] + [app.common.exceptions :as ex] + [app.common.fonts :as cfnt] + ;; Required for side effects: these register the transit read handlers and + ;; deftype impls the `get-page` response is decoded into. + [app.common.geom.matrix] + [app.common.geom.point] + [app.common.geom.rect] + [app.common.logging :as l] + [app.common.transit :as t] + [app.common.types.fills.impl] + [app.common.types.objects-map] + [app.common.types.path.impl] + [app.common.types.shape] + [app.common.types.shape.images :as images] + [app.common.uri :as u] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.util.mime :as mime] + [app.util.shell :as sh] + [app.wasm :as wasm] + [app.wasm.serialize :as serialize] + [cuerdas.core :as str] + [promesa.core :as p])) + +;; --- module lifecycle (one shared, lazily-initialized instance) + +(defonce ^:private module* (atom nil)) + +(defn- ensure-module! + [] + (or @module* + (reset! module* (wasm/init!)))) + +;; --- backend endpoints +;; +;; Every fetch targets the internal endpoint (falling back to public-uri), +;; in a deployment the exporter reaches the backend over the container network + +(defn- internal-uri + "Absolute URI for `path` on the internal (backend) endpoint." + [path] + (-> (cf/get-internal-uri) + (u/ensure-path-slash) + (u/join path) + (str))) + +(defn- public-uri + "Absolute URI for `path` on the public endpoint. Whoever opens an exported SVG + resolves its `@font-face` sources, so those cannot use the internal endpoint." + [path] + (-> (cf/get :public-uri) + (u/ensure-path-slash) + (u/join path) + (str))) + +(defn- error-detail + "Node's fetch reports every transport failure as a bare `TypeError: fetch + failed`; the actual reason (TLS rejection, DNS, ECONNREFUSED) is buried in a + nested `cause` chain that the logger does not print. Flattens the chain into + one readable string." + [cause] + (->> (iterate (fn [^js e] (unchecked-get e "cause")) cause) + (take-while some?) + (take 5) + (map (fn [^js e] + (let [code (unchecked-get e "code") + msg (or (unchecked-get e "message") (str e))] + (if code (str code ": " msg) msg)))) + (str/join " <- "))) + +(defn- fetch! + "`undici/fetch` that fails with an ex-info carrying the target uri and the + unwrapped cause chain, so a failed request says what actually went wrong and + against which endpoint." + [uri opts] + (->> (p/do (http/fetch uri opts)) + (p/merr (fn [cause] + (p/rejected (ex-info "http fetch failed" + {:uri uri :detail (error-detail cause)} + cause)))))) + +(defn- explain + "Log-friendly reason for `cause`: the detail `fetch!` already attached, or a + freshly unwrapped chain for anything else (WASM aborts, decode errors)." + [cause] + (or (:detail (ex-data cause)) + (error-detail cause))) + +(defn- rpc-headers + "Auth headers for backend RPC calls (management key + bearer)." + [token] + #js {"Content-Type" "application/transit+json" + "X-Shared-Key" (str "exporter " cf/management-key) + "Authorization" (str "Bearer " token)}) + +(defn- asset-headers + "Auth headers for `/assets/*`. Cookie, not Bearer: those endpoints redirect to + a presigned S3/minio URL, and a Bearer header makes S3 400 (\"multiple + authentication types\")." + [token] + #js {"X-Shared-Key" (str "exporter " cf/management-key) + "Cookie" (str "auth-token=" token)}) + +;; --- shape bundle fetch (backend RPC) + +(defn- fetch-objects + "Fetches the exported roots and their children from the backend via the + `get-page` RPC (`:object-id`, as the browser render path does), using the + same auth the exporter uses elsewhere (management key + bearer)." + [{:keys [file-id page-id share-id token objects]}] + (let [headers (rpc-headers token) + root-ids (into #{} (map :id) objects) + body (t/encode-str (cond-> {:file-id file-id + :page-id page-id} + (seq root-ids) (assoc :object-id root-ids) + share-id (assoc :share-id share-id))) + uri (internal-uri "api/rpc/command/get-page")] + (l/dbg :hint "wasm render: get-page" + :uri uri + :file-id (str file-id) + :page-id (str page-id) + :roots (count root-ids)) + (->> (fetch! uri #js {:method "POST" :headers headers :body body}) + (p/mcat (fn [^js resp] + (if (= 200 (.-status resp)) + (.text resp) + (->> (.text resp) + (p/mcat (fn [resp-body] + (l/error :hint "wasm render: get-page failed" + :uri uri + :status (.-status resp) + :body resp-body) + (p/rejected (ex-info "get-page failed" + {:status (.-status resp) + :body resp-body})))))))) + (p/fmap t/decode-str) + (p/fmap :objects)))) + +;; --- font resolution +;; +;; The text serializer keeps each font's real uuid, so `wasm/fonts-for-shape` +;; reports it. Custom (team) fonts resolve through the file's font variants, +;; google fonts through the shared `app.common.fonts` catalog; builtin +;; fonts through its bundled family + the frontend's static `/fonts/`. + +(defn- fetch-font-variants + "Team (custom) font variants for the file, or nil — a failure here degrades + to fallback fonts, it does not fail the export." + [{:keys [file-id share-id token]}] + (let [headers (rpc-headers token) + body (t/encode-str (cond-> {:file-id file-id} + share-id (assoc :share-id share-id))) + uri (internal-uri "api/rpc/command/get-font-variants")] + (->> (fetch! uri #js {:method "POST" :headers headers :body body}) + (p/mcat (fn [^js resp] + (if (= 200 (.-status resp)) + (.text resp) + (p/resolved nil)))) + (p/fmap (fn [s] (when s (t/decode-str s)))) + (p/merr (fn [cause] + (l/warn :hint "wasm render: get-font-variants failed" + :uri uri :detail (explain cause) :cause cause) + (p/resolved nil)))))) + +(defn- fetch-ttf-bytes + "Downloads a TTF, returning a promise of an ArrayBuffer (or nil). A failure + here degrades to fallback fonts, it does not fail the export." + ([uri] (fetch-ttf-bytes uri #js {:method "GET"})) + ([uri opts] + (->> (fetch! uri opts) + (p/mcat (fn [^js resp] + (if (= 200 (.-status resp)) + (.arrayBuffer resp) + (p/resolved nil)))) + (p/merr (fn [cause] + (l/warn :hint "wasm render: font fetch failed" + :uri uri :detail (explain cause) :cause cause) + (p/resolved nil)))))) + +;; TTF bytes cached for the process lifetime, keyed by whatever identifies the +;; variant (a gfont id+weight+style, a builtin file name). +(defonce ^:private font-bytes* (atom {})) + +(defn- cached-ttf-bytes + [cache-key fetch-fn] + (if-let [bytes (get @font-bytes* cache-key)] + (p/resolved bytes) + (->> (fetch-fn) + (p/fmap (fn [buf] + (when buf (swap! font-bytes* assoc cache-key buf)) + buf))))) + +(defn- fetch-asset-bytes + [asset-id {:keys [token]}] + (fetch-ttf-bytes (internal-uri (str "assets/by-id/" asset-id)) + #js {:method "GET" :headers (asset-headers token)})) + +(defn- fetch-gfont-bytes + [ttf-url] + (fetch-ttf-bytes (cfnt/gstatic->proxy-url ttf-url (internal-uri "internal/gfonts/font")))) + +(defn- fetch-builtin-font-bytes + [ttf-file] + (cached-ttf-bytes ttf-file #(fetch-ttf-bytes (internal-uri (str "fonts/" ttf-file))))) + +(defn- family-uuid + [id] + (uuid/from-unsigned-parts (aget id 0) (aget id 1) (aget id 2) (aget id 3))) + +(defn- find-variant + "Custom variant for a family: uuid+weight+style first, degrading to + uuid+weight then uuid." + [variants font-uuid weight style] + (let [style-str (if (zero? style) "normal" "italic")] + (or (d/seek (fn [v] (and (= (:font-id v) font-uuid) + (= (:font-weight v) weight) + (= (name (:font-style v)) style-str))) + variants) + (d/seek (fn [v] (and (= (:font-id v) font-uuid) + (= (:font-weight v) weight))) + variants) + (d/seek (fn [v] (= (:font-id v) font-uuid)) variants)))) + +(defn- make-resolve-font + "Builds a `resolve-font` fn (family map -> promise of TTF bytes). Custom + variants first; the bundled fonts for `uuid/zero`, which is what + `font-id->uuid` maps every builtin family to; google catalog otherwise." + [variants params] + (fn [{:keys [id weight style]}] + (let [font-uuid (family-uuid id) + variant (find-variant variants font-uuid weight style)] + (cond + (:ttf-file-id variant) + (fetch-asset-bytes (:ttf-file-id variant) params) + + (= uuid/zero font-uuid) + (fetch-builtin-font-bytes (cfnt/resolve-ttf-file weight style)) + + :else + (if-let [gurl (cfnt/resolve-ttf-url font-uuid weight style)] + (fetch-gfont-bytes gurl) + (p/resolved nil)))))) + +(defn- make-font-url + "Builds a `font-url` fn (family map -> public URL of its TTF), the same + sources `make-resolve-font` downloads from but addressed publicly. The SVG + export emits one `@font-face` per family from these." + [variants] + (fn [{:keys [id weight style]}] + (let [font-uuid (family-uuid id) + variant (find-variant variants font-uuid weight style)] + (cond + (:ttf-file-id variant) + (public-uri (str "assets/by-id/" (:ttf-file-id variant))) + + (= uuid/zero font-uuid) + (public-uri (str "fonts/" (cfnt/resolve-ttf-file weight style))) + + :else + (some-> (cfnt/resolve-ttf-url font-uuid weight style) + (cfnt/gstatic->proxy-url (public-uri "internal/gfonts/font"))))))) + +;; --- fallback fonts (emoji + per-script noto fonts) +;; +;; Emoji and non-latin scripts render through fallback families, not through +;; any span's font family, so `wasm/fonts-for-shape` never reports them and the +;; provisioning above never uploads them. Must run per request, since +;; `clear-fonts!` empties the store; the TTF bytes stay cached per process. + +(defn- scene-fallback-fonts + "Fallback font descriptors needed by the scene's text. Deduped because + several languages map to one noto family and provisioning is concurrent — + otherwise they all miss the byte cache at once and refetch the same TTF." + [scene] + (let [texts (for [shape (vals scene) + :when (= :text (:type shape)) + node (or (some->> (:content shape) (tree-seq :children :children)) []) + :let [text (:text node)] + :when (string? text)] + text) + emoji? (boolean (some cfnt/contains-emoji? texts)) + langs (reduce cfnt/collect-used-languages #{} texts)] + (distinct + (cond-> (cfnt/add-noto-fonts [] langs) + emoji? (cfnt/add-emoji-font))))) + +(defn- fetch-fallback-font-bytes + "Downloads one fallback font's TTF. Cached by the whole variant, not just + `font-id`: `resolve-ttf-url` picks a different TTF per weight/style, so a + font-id-only key would serve the first downloaded variant for every other one." + [{:keys [font-id weight style]}] + (if-let [ttf-url (some-> (cfnt/gfont-id->uuid font-id) (cfnt/resolve-ttf-url weight style))] + (cached-ttf-bytes [font-id weight style] #(fetch-gfont-bytes ttf-url)) + (p/resolved nil))) + +(defn- provision-fallback-fonts! + [scene] + (->> (scene-fallback-fonts scene) + (map (fn [{:keys [font-id weight style is-emoji is-fallback] :as font}] + (if-let [font-uuid (cfnt/gfont-id->uuid font-id)] + (->> (fetch-fallback-font-bytes font) + (p/fmap (fn [buf] + (if buf + (wasm/store-font! {:id (uuid/get-u32 font-uuid) + :weight weight + :style style + :emoji? (boolean is-emoji) + :fallback? (boolean is-fallback)} + buf) + (l/warn :hint "wasm render: fallback font unavailable" + :font-id font-id))))) + (p/resolved nil)))) + (p/all))) + +;; --- image resolution +;; +;; Image fills reference file-media ids; the encoded bytes go straight to +;; `_store_image` (Skia decodes, no WebGL), keyed by media uuid so this happens +;; once per request rather than per rendered object. + +(defn- fetch-file-media-bytes + "Downloads an image fill's encoded bytes by file-media id." + [media-id {:keys [token]}] + (let [headers (asset-headers token) + uri (internal-uri (str "assets/by-file-media-id/" media-id))] + (->> (fetch! uri #js {:method "GET" :headers headers}) + (p/mcat (fn [^js resp] + (if (= 200 (.-status resp)) + (.arrayBuffer resp) + (do + (l/warn :hint "wasm render: image fetch non-200" + :media-id (str media-id) + :uri uri + :status (.-status resp)) + (p/resolved nil))))) + (p/merr (fn [cause] + (l/warn :hint "wasm render: image fetch failed" + :media-id (str media-id) :uri uri + :detail (explain cause) :cause cause) + (p/resolved nil)))))) + +(defn- provision-images! + "Fetches and stores every image the scene references (shape, stroke and + text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts, + the image store is not reset per request, so already-held images are skipped + and repeated exports of a file reuse them." + [scene params] + (let [all-ids (images/scene-image-ids scene) + new-ids (remove wasm/image-cached? all-ids)] + (l/dbg :hint "wasm render: provisioning images" + :total (count all-ids) + :cached (- (count all-ids) (count new-ids))) + (->> new-ids + (map (fn [image-id] + (->> (fetch-file-media-bytes image-id params) + (p/fmap (fn [buf] + (if buf + (do + (l/dbg :hint "wasm render: image stored" + :media-id (str image-id) + :bytes (.-byteLength ^js buf)) + (wasm/store-image! image-id buf)) + (l/warn :hint "wasm render: image unavailable" + :media-id (str image-id)))))))) + (p/all)))) + +(defn- relayout-text! + "Recomputes layout for every text shape, once the real fonts are provisioned + (serialize-time layout used the fallback)." + [scene] + (doseq [shape (vals scene) + :when (= :text (:type shape))] + (wasm/update-text-layout! (:id shape)))) + +;; --- render + +(defn- check-cancelled! + "Cancellation is cooperative: a render already inside Skia cannot be + interrupted, so the flag is only observed between objects. Killing a job + mid-object is the caller's job (terminating the worker)." + [{:keys [cancelled?] :as _params}] + (when (and cancelled? (cancelled?)) + (ex/raise :type :internal + :code :job-cancelled + :hint "export job was cancelled"))) + +(defn- render-object-bytes + [type id scale] + (case type + :pdf (let [bytes (wasm/render-shape-pdf id scale)] + (l/dbg :hint "PDF generated via Skia (render-wasm headless)" + :object-id (str id) + :backend "skia-wasm" + :bytes (.-length bytes)) + bytes) + :svg (let [bytes (wasm/render-shape-svg id scale)] + (l/dbg :hint "SVG generated via Skia (render-wasm headless)" + :object-id (str id) + :backend "skia-wasm" + :bytes (.-length bytes)) + bytes) + (wasm/render-shape-raster id scale type))) + +(defn- render* + [{:keys [scale type objects] :as params} on-object] + (l/dbg :hint "wasm render: start" + :type type + :scale scale + :objects (count objects) + :file-id (str (:file-id params)) + :page-id (str (:page-id params))) + (->> (ensure-module!) + (p/mcat (fn [_] (fetch-objects params))) + (p/mcat (fn [scene] + (l/dbg :hint "wasm render: scene fetched" :shapes (count scene)) + (serialize/serialize-scene! scene) + (l/dbg :hint "wasm render: scene serialized") + ;; So fonts from a previous request don't leak into this one. + (wasm/clear-fonts!) + (->> (p/all [(fetch-font-variants params) + (provision-images! scene params) + (provision-fallback-fonts! scene)]) + (p/mcat + (fn [[variants _]] + (let [variants (or variants []) + resolve-font (make-resolve-font variants params) + font-url (make-font-url variants)] + ;; Before rendering, so the relayout below sees real + ;; font metrics. Deduped across objects: shapes + ;; sharing one family download its TTF once. + (wasm/provision-fonts! (map :id objects) resolve-font + :font-url font-url)))) + (p/mcat + (fn [_] + (relayout-text! scene) + (p/run + (fn [{:keys [id] :as object}] + (check-cancelled! params) + (let [bytes (render-object-bytes type id scale) + path (sh/tempfile :prefix "penpot.tmp.wasm." + :suffix (mime/get-extension type))] + (l/dbg :hint "wasm render: object rendered" + :object-id (str id) :bytes (.-length bytes)) + (fs/writeFileSync path bytes) + ;; `on-object` returns a plain value (zip append) or + ;; a promise (single export's file move); `p/do` + ;; normalizes both to a thenable. + (p/do (on-object (assoc object :path path))))) + objects)))))) + (p/fmap (fn [result] + ;; After the request, never mid-render, so an image can't + ;; disappear under a running export. + (let [evicted (wasm/evict-images! (cf/get :wasm-worker-image-cache-size wasm/image-cache-size))] + (when (pos? evicted) + (l/info :hint "wasm render: evicted cached images" :count evicted))) + result)) + (p/merr (fn [cause] + (l/error :hint "wasm render: failed" + :detail (explain cause) + :internal-uri (str (cf/get-internal-uri)) + :cause cause) + ;; A panic can leave the mem buffer allocated or the instance + ;; aborted; drop it so the next request rebuilds a fresh one. + (reset! module* nil) + (p/rejected cause))))) + +(defn render + "Public entry. Renders every object of `params`, calling `on-object` with + `{:id :filename :path ...}` as each one is written out." + [params on-object] + (render* params on-object)) diff --git a/exporter/src/app/wasm/serialize.cljs b/exporter/src/app/wasm/serialize.cljs new file mode 100644 index 0000000000..f3edb39a6a --- /dev/null +++ b/exporter/src/app/wasm/serialize.cljs @@ -0,0 +1,46 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.wasm.serialize + "Browser-free shape serialization for the headless exporter: the counterpart + of `app.render-wasm.api/set-object`, which cannot be reused directly because + its namespace pulls React/DOM/store. Only the call sequencing lives here — + every byte layout comes from the shared serializers, so the bytes sent to + WASM are the editor's. + + Covers everything except svg-raw. Image bytes and fonts are provisioned + separately by `app.renderer.wasm`." + (:require + [app.common.render-wasm.api.props :as props] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.serialize-shape :as serialize-shape] + [app.common.render-wasm.wasm :as wasm] + [app.wasm.text :as text])) + +(defn set-shape! + "Serializes a single shape into the WASM design state. The host-independent + properties (base props, children, blur, shadows, svg-attrs, mask, bool-type, + path geometry, grow-type) go through the shared `serialize-shape!` — the same + code the workspace's `set-object` uses, so the two can't drift. Only the + host-specific parts are handled here: fills/strokes (image bytes are provisioned + separately) and text content (fonts provisioned separately)." + [shape] + (let [type (get shape :type)] + (serialize-shape/serialize-shape! shape) + (props/write-shape-fills! (get shape :fills)) + (when-not (= type :group) + (props/write-shape-strokes! (get shape :strokes))) + (when (= type :text) + (text/set-shape-text! (get shape :content))))) + +(defn serialize-scene! + "Loads every shape of an `objects` map into the WASM design state. Resets the + shapes pool first so repeated exports don't accumulate into the shared + state. Order is irrelevant: shapes reference each other by id and the tree + is resolved at render time." + [objects] + (h/call wasm/internal-module "_init_shapes_pool" (count objects)) + (run! set-shape! (vals objects))) diff --git a/exporter/src/app/wasm/text.cljs b/exporter/src/app/wasm/text.cljs new file mode 100644 index 0000000000..13b037652b --- /dev/null +++ b/exporter/src/app/wasm/text.cljs @@ -0,0 +1,35 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.wasm.text + "Browser-free text-content serialization for the headless exporter. Only the + paragraph walk is local: the binary layout and the font-id -> uuid mapping + both come from `app.common.render-wasm.text-content`." + (:require + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.text-content :as tc] + [app.common.render-wasm.wasm :as wasm])) + +(defn set-shape-text! + "Serializes a text shape's content into the current WASM shape. Mirrors the + editor's sequence: clear -> vertical-align -> append each paragraph -> layout. + Byte writing and font resolution are the shared + `text-content/write-shape-text!` defaults; the exporter has no fonts DB, so + it injects no variant normalization." + [content] + (when content + (h/call wasm/internal-module "_clear_shape_text") + (h/call wasm/internal-module "_set_shape_vertical_align" + (sr/translate-vertical-align (get content :vertical-align))) + (let [paragraph-set (first (get content :children)) + paragraphs (get paragraph-set :children)] + (doseq [paragraph paragraphs] + (let [spans (get paragraph :children)] + (when (seq spans) + (let [text (apply str (map :text spans))] + (tc/write-shape-text! spans paragraph text {})))))) + (h/call wasm/internal-module "_update_shape_text_layout"))) diff --git a/exporter/src/app/wasm/worker.cljs b/exporter/src/app/wasm/worker.cljs new file mode 100644 index 0000000000..c58f0f9cb2 --- /dev/null +++ b/exporter/src/app/wasm/worker.cljs @@ -0,0 +1,71 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.wasm.worker + "Render worker entry point. + + Owns one render-wasm module and renders one export request at a time. The + Skia calls are synchronous, so running them here is what lets several exports + progress at once: the main thread keeps serving HTTP, zipping and uploading + while this thread is blocked inside a render. + + Messages in: {type: \"render\", params: <transit>, cancel: SharedArrayBuffer} + Messages out: {type: \"ready\"} + {type: \"object\", payload: <transit>} one per rendered object + {type: \"done\"} | {type: \"error\", message, code}" + (:require + ["node:worker_threads" :as wt] + [app.common.logging :as l] + [app.common.transit :as t] + [app.wasm.render :as render] + [promesa.core :as p])) + +(defn- post! + [message] + (.postMessage ^js wt/parentPort message)) + +(defn- cancelled-fn + [buffer] + (if (some? buffer) + (let [signal (js/Int32Array. buffer)] + (fn [] (pos? (js/Atomics.load signal 0)))) + (constantly false))) + +(defn- handle-render + [data] + (let [params (-> (unchecked-get data "params") + (t/decode-str) + (assoc :cancelled? (cancelled-fn (unchecked-get data "cancel"))))] + (->> (render/render params + (fn [object] + (post! #js {:type "object" :payload (t/encode-str object)}))) + (p/fmap (fn [_] (post! #js {:type "done"}))) + (p/merr (fn [cause] + (l/warn :hint "render worker: request failed" :cause cause) + (post! #js {:type "error" + :message (or (ex-message cause) (str cause)) + :code (some-> cause ex-data :code name)}) + (p/resolved nil)))))) + +(defn- on-message + [data] + (case (unchecked-get data "type") + "render" (handle-render data) + (l/warn :hint "render worker: unknown message" :type (unchecked-get data "type")))) + +(defonce ^:private listening + ;; `defonce` survives a hot reload, so a reload does not stack a second + ;; listener on the port. The indirection through the var keeps the reloaded + ;; `on-message` in play instead of pinning the one captured at boot. + (delay + (.on ^js wt/parentPort "message" (fn [data] (on-message data))) + true)) + +(defn main + [& _] + @listening + (post! #js {:type "ready"}) + (l/info :hint "render worker ready")) diff --git a/exporter/test/exporter_tests/export_shapes_test.cljs b/exporter/test/exporter_tests/export_shapes_test.cljs new file mode 100644 index 0000000000..d512cabd78 --- /dev/null +++ b/exporter/test/exporter_tests/export_shapes_test.cljs @@ -0,0 +1,31 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns exporter-tests.export-shapes-test + "Chunking of the browser backend." + (:require + [app.common.uuid :as uuid] + [app.handlers.export-shapes :as export-shapes] + [cljs.test :as t :include-macros true])) + +(defn- exports + [n type scale] + (let [file-id (uuid/next) + page-id (uuid/next)] + (mapv (fn [i] + {:file-id file-id + :page-id page-id + :object-id (uuid/next) + :name (str "shape-" i) + :suffix "" + :scale scale + :type type}) + (range n)))) + +(t/deftest browser-exports-are-chunked + (let [parts (export-shapes/prepare-exports (exports 120 :png 1) "token" false)] + (t/is (= 3 (count parts))) + (t/is (= [50 50 20] (mapv (comp count :objects) parts))))) diff --git a/exporter/test/exporter_tests/jobs_test.cljs b/exporter/test/exporter_tests/jobs_test.cljs new file mode 100644 index 0000000000..899c0f350f --- /dev/null +++ b/exporter/test/exporter_tests/jobs_test.cljs @@ -0,0 +1,86 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns exporter-tests.jobs-test + "Job state machine. Runs without redis: a store write with no connection is + reported and swallowed, so only the in-process record is exercised." + (:require + [app.common.uuid :as uuid] + [app.jobs :as jobs] + [cljs.test :as t :include-macros true] + [promesa.core :as p])) + +(defn- create! + [] + (jobs/create! {:profile-id (uuid/next) + :cmd :export-shapes + :backend "wasm" + :total 10 + :name "test" + :resource-id (uuid/next)} + (constantly (p/resolved nil)))) + +(t/deftest progress-does-not-resurrect-a-finished-job + (t/testing "a render reporting after the export failed cannot undo the failure" + (t/async done + (p/let [job (create!) + _ (jobs/start! job) + _ (jobs/fail! (jobs/lookup (:id job)) (ex-info "boom" {})) + ;; `job` is the snapshot handed to the work when it started, which + ;; is what a straggling render still holds. + _ (jobs/progress! job 7)] + (let [current (jobs/lookup (:id job))] + (t/is (= "error" (:state current))) + (t/is (= "boom" (:error current))) + (t/is (not= 7 (:done current)))) + (jobs/release! (:id job)) + (done))))) + +(t/deftest first-terminal-state-wins + (t/testing "a failure arriving after a cancellation leaves the job cancelled" + (t/async done + (p/let [job (create!) + _ (jobs/start! job) + _ (jobs/cancel! (:id job)) + _ (jobs/fail! job (ex-info "too late" {}))] + (let [current (jobs/lookup (:id job))] + (t/is (= "cancelled" (:state current))) + (t/is (nil? (:error current)))) + (jobs/release! (:id job)) + (done))))) + +(t/deftest cancel-is-recorded-before-the-callbacks-run + (t/testing "a queued job dropped by its own cancel callback still ends cancelled" + (t/async done + (let [seen (atom ::not-called)] + (p/let [job (create!) + ;; What `scheduler/drop-queued!` does: it takes the job off the + ;; queue and releases it. Anything the lifecycle wrote after + ;; the callbacks ran would be dropped on the floor, so by the + ;; time one is called the record has to be terminal already. + _ (jobs/on-cancel (:id job) + (fn [] + (reset! seen (:state (jobs/lookup (:id job)))) + (jobs/release! (:id job)))) + _ (jobs/cancel! (:id job))] + (t/is (= "cancelled" @seen)) + (t/is (nil? (jobs/lookup (:id job)))) + (done)))))) + +(t/deftest writes-stop-once-the-job-is-released + (t/testing "a late write for a job the scheduler already settled is dropped" + (t/async done + (p/let [job (create!) + _ (jobs/start! job) + _ (jobs/complete! (jobs/lookup (:id job)) {:uri "http://example/x" + :filename "x.zip" + :mtype "application/zip"}) + ended (jobs/lookup (:id job)) + _ (jobs/release! (:id job)) + _ (jobs/progress! job 3)] + (t/is (= "ended" (:state ended))) + (t/is (nil? (jobs/lookup (:id job)))) + (done))))) diff --git a/exporter/test/exporter_tests/renderer_svg_test.cljs b/exporter/test/exporter_tests/renderer_svg_test.cljs new file mode 100644 index 0000000000..3c841a2d77 --- /dev/null +++ b/exporter/test/exporter_tests/renderer_svg_test.cljs @@ -0,0 +1,25 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns exporter-tests.renderer-svg-test + (:require + [app.renderer.svg-gradient :as svg-gradient] + [cljs.test :refer [deftest is testing]])) + +(def gradient-stops + [{"color" "#000000" "offset" 0 "opacity" 1} + {"color" "#ffffff" "offset" 1 "opacity" 1}]) + +(deftest creates-the-correct-gradient-element + (doseq [[gradient-type element-name] + [["linear" "linearGradient"] + ["radial" "radialGradient"]]] + (testing gradient-type + (let [gradient-data {"type" "gradient" + "gradient" {"type" gradient-type + "stops" gradient-stops}} + result (svg-gradient/data->gradient-def "text-id" ["#000001" gradient-data])] + (is (= element-name (get result "name"))))))) diff --git a/exporter/test/exporter_tests/runner.cljs b/exporter/test/exporter_tests/runner.cljs new file mode 100644 index 0000000000..82c3e4c46f --- /dev/null +++ b/exporter/test/exporter_tests/runner.cljs @@ -0,0 +1,180 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns exporter-tests.runner + (:require + [app.common.logging :as l] + [cljs.test :as t] + [clojure.string :as str] + [clojure.tools.cli :refer [parse-opts]] + [exporter-tests.export-shapes-test] + [exporter-tests.jobs-test] + [exporter-tests.renderer-svg-test] + [exporter-tests.scheduler-test] + [exporter-tests.shell-test] + [exporter-tests.wasm-pool-test] + [goog.object :as gobj])) + +(enable-console-print!) + +(def test-namespaces + ['exporter-tests.export-shapes-test + 'exporter-tests.jobs-test + 'exporter-tests.renderer-svg-test + 'exporter-tests.scheduler-test + 'exporter-tests.shell-test + 'exporter-tests.wasm-pool-test]) + +(assert (every? find-ns-obj test-namespaces) + "test-namespaces contains a namespace that isn't required in runner.cljs") + +(defmethod t/report [:cljs.test/default :begin-test-var] + [m] + (let [v (:var m)] + (println (str " ▸ " (:ns (meta v)) "/" (:name (meta v)))))) + +(defmethod t/report [:cljs.test/default :end-run-tests] + [result] + (.exit js/process (if (cljs.test/successful? result) 0 1))) + +(def ^:private log-levels + #{:trace :debug :info :warn :error}) + +(def cli-options + [["-f" "--focus FOCUS" "Run one test namespace or one test var, e.g. exporter-tests.renderer-svg-test/creates-the-correct-gradient-element"] + ["-l" "--log-level LEVEL" "Set app logger level: trace|debug|info|warn|error" + :parse-fn keyword + :validate [log-levels "must be one of trace, debug, info, warn, error"]] + ["-h" "--help"]]) + +(defn- argv + [] + (let [args (->> (.-argv js/process) + (array-seq) + (drop 2))] + ;; `pnpm run test -- --focus ...` forwards the separator to the node + ;; process, so drop one leading `--` before handing args to tools.cli. + (cond-> args + (= "--" (first args)) rest))) + +(defn- usage + [summary] + (str "Usage: node target/tests/test.js [options]\n\n" + "Options:\n" + summary "\n\n" + "Build first with: pnpm run build:test\n\n" + "Focus examples:\n" + " node target/tests/test.js --focus exporter-tests.renderer-svg-test\n" + " node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element\n\n" + "Log level example:\n" + " node target/tests/test.js --focus exporter-tests.renderer-svg-test --log-level warn")) + +(defn- fail! + [message] + (js/console.error message) + (.exit js/process 1)) + +(defn- parse-focus + [focus] + (let [[ns-name test-name & extra] (str/split focus #"/")] + (cond + (or (str/blank? ns-name) (seq extra)) + (fail! (str "Invalid --focus value: " focus)) + + (some? test-name) + {:ns (symbol ns-name) :test test-name} + + :else + {:ns (symbol ns-name)}))) + +(defn- fixture-value + [ns-obj fixture-name] + (let [value (gobj/get ns-obj (munge fixture-name))] + (when-not (undefined? value) + value))) + +(defn- ns-test-vars + [ns-sym] + (when-let [ns-obj (find-ns-obj ns-sym)] + (->> (js-keys ns-obj) + (keep (fn [key] + (some-> (gobj/get ns-obj key) + (.-cljs$lang$var)))) + (filter (comp :test meta)) + (sort-by (comp :line meta))))) + +(defn- ns-fixtures + [ns-sym vars] + (when-let [ns-obj (find-ns-obj ns-sym)] + (let [ns-key (or (some-> vars first meta :ns) ns-sym) + once-fixtures (fixture-value ns-obj "cljs-test-once-fixtures") + each-fixtures (fixture-value ns-obj "cljs-test-each-fixtures")] + {:once (when once-fixtures {ns-key once-fixtures}) + :each (when each-fixtures {ns-key each-fixtures})}))) + +(defn- selected-tests + [{:keys [ns test]}] + (when-not (some #{ns} test-namespaces) + (fail! (str "Unknown test namespace: " ns))) + (let [vars (vec (ns-test-vars ns))] + (when (empty? vars) + (fail! (str "No tests found in namespace: " ns))) + (if test + (let [test-sym (symbol test) + test-var (some #(when (= test-sym (:name (meta %))) %) vars)] + (if test-var + {:vars [test-var] + :fixtures (ns-fixtures ns [test-var])} + (fail! (str "Unknown test var: " ns "/" test)))) + {:vars vars + :fixtures (ns-fixtures ns vars)}))) + +(defn- merge-fixtures + [fixtures] + {:once (apply merge (keep :once fixtures)) + :each (apply merge (keep :each fixtures))}) + +(defn- run-test-vars! + [tests] + (let [vars (vec (mapcat :vars tests)) + fixtures (merge-fixtures (map :fixtures tests)) + env (assoc (t/empty-env) + :once-fixtures (:once fixtures) + :each-fixtures (:each fixtures)) + summary (volatile! {:test 0 :pass 0 :fail 0 :error 0 :type :summary})] + (t/set-env! env) + (t/run-block + (concat (t/test-vars-block vars) + [(fn [] + (vswap! summary + (partial merge-with +) + (:report-counters (t/get-current-env)))) + (fn [] + (t/report @summary) + (t/report (assoc @summary :type :end-run-tests)))])))) + +(defn- run-focused-test! + [focus] + (run-test-vars! [(selected-tests (parse-focus focus))])) + +(defn -main + [] + (let [{:keys [options errors summary]} (parse-opts (argv) cli-options)] + (cond + (seq errors) + (fail! (str/join "\n" errors)) + + (:help options) + (do + (println (usage summary)) + (.exit js/process 0)) + + :else + (do + (l/setup! {:app (or (:log-level options) :warn)}) + (if (:focus options) + (run-focused-test! (:focus options)) + (run-test-vars! (map #(selected-tests {:ns %}) test-namespaces))))))) diff --git a/exporter/test/exporter_tests/scheduler_test.cljs b/exporter/test/exporter_tests/scheduler_test.cljs new file mode 100644 index 0000000000..48c3791632 --- /dev/null +++ b/exporter/test/exporter_tests/scheduler_test.cljs @@ -0,0 +1,81 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns exporter-tests.scheduler-test + "Admission control. A headless job leases one render worker for its whole run, + so no more of them may start than there are workers." + (:require + [app.common.uuid :as uuid] + [app.jobs :as jobs] + [app.jobs.scheduler :as scheduler] + [app.wasm.pool :as pool] + [cljs.test :as t :include-macros true] + [promesa.core :as p])) + +(defn- pin-capacity! + "Fixes the worker count for the test. Returns the thunk that puts it back; + `with-redefs` cannot be used here, since the scheduler keeps admitting jobs + after the body of the test has returned." + [n] + (let [original pool/capacity] + (set! pool/capacity (constantly n)) + (fn [] (set! pool/capacity original)))) + +(defn- create! + [backend run-fn] + (jobs/create! {:profile-id (uuid/next) + :cmd :export-shapes + :backend backend + :total 1 + :name "test" + :resource-id (uuid/next)} + run-fn)) + +(defn- gate + "A promise and the fn that settles it, standing in for a render in flight." + [] + (let [resolve* (volatile! nil) + pending (p/create (fn [resolve _] (vreset! resolve* resolve)))] + [pending (fn [] (@resolve* nil))])) + +(t/deftest headless-jobs-wait-for-a-render-worker + (t/testing "with one worker, the second headless job stays queued until the first ends" + (t/async done + (let [restore! (pin-capacity! 1) + [render open] (gate) + started (atom [])] + (p/let [job1 (create! "wasm" (fn [_] (swap! started conj :one) render)) + job2 (create! "wasm" (fn [_] (swap! started conj :two) (p/resolved nil)))] + (let [p1 (scheduler/submit! job1) + p2 (scheduler/submit! job2)] + (p/do + (p/delay 10) + (t/is (= [:one] @started)) + (t/is (= "running" (:state (jobs/lookup (:id job1))))) + (t/is (= "queued" (:state (jobs/lookup (:id job2))))) + (open) + (p/all [p1 p2]) + (t/is (= [:one :two] @started)) + (restore!) + (done)))))))) + +(t/deftest a-browser-job-is-not-held-back-by-the-worker-pool + (t/testing "the headless cap applies to headless jobs only" + (t/async done + (let [restore! (pin-capacity! 1) + [render open] (gate) + started (atom [])] + (p/let [job1 (create! "wasm" (fn [_] (swap! started conj :wasm) render)) + job2 (create! "browser" (fn [_] (swap! started conj :browser) (p/resolved nil)))] + (let [p1 (scheduler/submit! job1) + p2 (scheduler/submit! job2)] + (p/do + (p/delay 10) + (t/is (= [:wasm :browser] @started)) + (open) + (p/all [p1 p2]) + (restore!) + (done)))))))) diff --git a/exporter/test/exporter_tests/shell_test.cljs b/exporter/test/exporter_tests/shell_test.cljs new file mode 100644 index 0000000000..5858328eb3 --- /dev/null +++ b/exporter/test/exporter_tests/shell_test.cljs @@ -0,0 +1,70 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns exporter-tests.shell-test + "Tests to verify GHSA-4f36-m4hj-cv86 is fixed: OS Command Injection in SVG exporter. + These tests prove that: + 1. execFile does NOT interpret shell metacharacters (safe execution) + 2. Malicious colors fail validation regex + 3. The injection does NOT execute commands (no RCE)" + (:require + ["node:child_process" :as proc] + ["node:fs" :as fs] + [cljs.test :as t :include-macros true])) + +(def ^:private hex-color-rx + #"^#(?:[0-9a-fA-F]{3}){1,2}$") + +(defn- valid-hex-color? + [color] + (and (string? color) + (some? (re-matches hex-color-rx color)))) + +(t/deftest execfile-does-not-interpret-shell-metacharacters + (t/testing "Proves execFile passes arguments literally (no shell interpretation)" + (t/async done + (let [cmd "echo" + args #js ["$(echo PWNED)"]] + (proc/execFile cmd args #js {:encoding "buffer"} + (fn [error stdout _stderr] + (if error + (do + (t/is false (str "unexpected error: " (.-message error))) + (done)) + (let [output (.toString stdout "utf8")] + (t/is (= "$(echo PWNED)\n" output) + "execFile passes $(...) literally, no shell interpretation") + (done))))))))) + +(t/deftest malicious-color-fails-validation + (t/testing "Proves malicious colors are rejected by validation" + (let [malicious "#000000$(echo PWNED)" + valid-color "#000000" + short-valid "#abc"] + (t/is (not (valid-hex-color? malicious)) + "malicious color with $(...) fails validation") + (t/is (valid-hex-color? valid-color) + "valid 6-digit hex color passes validation") + (t/is (valid-hex-color? short-valid) + "valid 3-digit hex color passes validation")))) + +(t/deftest execfile-does-not-execute-injected-commands + (t/testing "Proves execFile does NOT execute injected commands (no RCE)" + (t/async done + (let [marker "/tmp/penpot-exporter-rce-test" + malicious (str "#000000$(touch " marker ")") + cmd "echo" + args #js [malicious]] + (when (fs/existsSync marker) + (fs/unlinkSync marker)) + (proc/execFile cmd args #js {:encoding "buffer"} + (fn [_error _stdout _stderr] + ;; Command completes (or fails), but no injection occurs + (t/is (not (fs/existsSync marker)) + "no RCE: marker file was NOT created") + (when (fs/existsSync marker) + (fs/unlinkSync marker)) + (done))))))) diff --git a/exporter/test/exporter_tests/wasm_pool_test.cljs b/exporter/test/exporter_tests/wasm_pool_test.cljs new file mode 100644 index 0000000000..4bf85eaa82 --- /dev/null +++ b/exporter/test/exporter_tests/wasm_pool_test.cljs @@ -0,0 +1,46 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns exporter-tests.wasm-pool-test + "Worker leasing, against a stub pool: `with-worker` must give the worker back + however its body ends." + (:require + [app.wasm.pool :as pool] + [cljs.test :as t :include-macros true] + [promesa.core :as p])) + +(defn- stub-pool! + "Installs a pool whose acquire/release/destroy only count calls." + [] + (let [calls (atom {:acquired 0 :released 0 :destroyed 0})] + (reset! pool/pool + #js {:acquire (fn [] (swap! calls update :acquired inc) (p/resolved ::worker)) + :release (fn [_] (swap! calls update :released inc) (p/resolved nil)) + :destroy (fn [_] (swap! calls update :destroyed inc) (p/resolved nil))}) + calls)) + +(t/deftest releases-the-worker-when-the-body-succeeds + (t/async done + (let [calls (stub-pool!)] + (p/let [result (pool/with-worker (fn [_] (p/resolved :ok)))] + (t/is (= :ok result)) + (t/is (= 1 (:acquired @calls))) + (t/is (= 1 (:released @calls))) + (t/is (= 0 (:destroyed @calls))) + (reset! pool/pool nil) + (done))))) + +(t/deftest gives-the-worker-back-when-the-body-throws-synchronously + (t/testing "a raise out of the scope body must not leave the worker borrowed" + (t/async done + (let [calls (stub-pool!)] + (->> (pool/with-worker (fn [_] (throw (ex-info "cancelled" {})))) + (p/hmap (fn [_ cause] + (t/is (some? cause)) + (t/is (= 1 (:acquired @calls))) + (t/is (= 1 (+ (:released @calls) (:destroyed @calls)))) + (reset! pool/pool nil) + (done)))))))) diff --git a/frontend/deps.edn b/frontend/deps.edn index a662b7086d..789020ef56 100644 --- a/frontend/deps.edn +++ b/frontend/deps.edn @@ -1,9 +1,10 @@ {:paths ["src" "vendor" "resources" "test"] :deps - {penpot/common + {;; Carries `app.common.render-wasm.*`, shared with the headless exporter. + penpot/common {:local/root "../common"} - org.clojure/clojure {:mvn/version "1.12.2"} + org.clojure/clojure {:mvn/version "1.12.5"} binaryage/devtools {:mvn/version "RELEASE"} metosin/reitit-core {:mvn/version "0.10.1"} funcool/okulary {:mvn/version "2022.04.11-16"} @@ -20,8 +21,8 @@ :exclusions [funcool/beicon2]} funcool/beicon2 - {:git/tag "v2.2" - :git/sha "8744c66" + {:git/tag "v2.3" + :git/sha "df7058a" :git/url "https://github.com/funcool/beicon.git"} funcool/rumext @@ -50,7 +51,7 @@ "--enable-native-access=ALL-UNNAMED"] :extra-deps - {thheller/shadow-cljs {:mvn/version "3.4.11"} + {thheller/shadow-cljs {:mvn/version "3.5.0"} com.bhauman/rebel-readline {:mvn/version "RELEASE"} org.clojure/tools.namespace {:mvn/version "RELEASE"} criterium/criterium {:mvn/version "0.4.6"}}} diff --git a/frontend/dev/user.clj b/frontend/dev/user.clj index ae9f11904f..30d3a811f1 100644 --- a/frontend/dev/user.clj +++ b/frontend/dev/user.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns user (:require diff --git a/frontend/package.json b/frontend/package.json index 54063ed48f..c319bcab55 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "browserslist": [ "defaults" ], @@ -19,7 +19,7 @@ "build:storybook": "(cd packages/ui && pnpm run build) && pnpm run build:storybook:assets && pnpm run build:storybook:cljs && storybook build", "build:storybook:assets": "node ./scripts/build-storybook-assets.js", "build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook", - "build:wasm": "../render-wasm/build", + "build:wasm": "../render-wasm/build frontend", "build:app:libs": "node ./scripts/build-libs.js", "build:app:main": "clojure -M:dev:shadow-cljs release main worker", "build:app:worker": "clojure -M:dev:shadow-cljs release worker", @@ -59,33 +59,33 @@ "@penpot/tokenscript": "link:packages/tokenscript", "@penpot/ua-parser": "penpot/ua-parser#1.0.0", "@playwright/test": "1.62.1", - "@storybook/addon-docs": "10.5.5", - "@storybook/addon-themes": "10.5.5", - "@storybook/addon-vitest": "10.5.5", - "@storybook/react-vite": "10.5.5", + "@storybook/addon-docs": "10.5.10", + "@storybook/addon-themes": "10.5.10", + "@storybook/addon-vitest": "10.5.10", + "@storybook/react-vite": "10.5.10", "@tokens-studio/sd-transforms": "2.0.3", - "@types/node": "^26.1.2", - "@vitest/browser": "4.1.10", - "@vitest/browser-playwright": "4.1.10", - "@vitest/coverage-v8": "4.1.10", + "@types/node": "^26.4.0", + "@vitest/browser": "4.1.11", + "@vitest/browser-playwright": "4.1.11", + "@vitest/coverage-v8": "4.1.11", "@zip.js/zip.js": "2.8.34", "autoprefixer": "^10.5.4", "compression": "^1.8.1", - "concurrently": "^10.0.4", + "concurrently": "^10.0.5", "date-fns": "^4.4.0", - "esbuild": "^0.28.1", - "eventsource-parser": "^3.1.0", + "esbuild": "^0.28.2", + "eventsource-parser": "^4.1.0", "express": "^5.1.0", "fancy-log": "^2.0.0", "getopts": "^2.3.0", "gettext-parser": "^9.1.1", - "highlight.js": "^11.10.0", + "highlight.js": "^11.12.0", "js-beautify": "^2.0.3", "jsdom": "^30.0.1", "lodash": "^4.18.1", "lodash.debounce": "^4.0.8", "map-stream": "0.0.7", - "marked": "^18.0.7", + "marked": "^18.0.11", "mkdirp": "^3.0.1", "mustache": "^4.2.0", "nodemon": "^3.1.14", @@ -93,7 +93,7 @@ "opentype.js": "^2.0.0", "p-limit": "^7.3.1", "playwright": "1.62.1", - "postcss": "^8.5.25", + "postcss": "^8.5.26", "postcss-clean": "^1.2.2", "postcss-modules": "^9.0.1", "postcss-scss": "^4.0.9", @@ -103,27 +103,27 @@ "randomcolor": "^0.6.2", "react": "19.2.8", "react-dom": "19.2.8", - "react-error-boundary": "^6.1.2", + "react-error-boundary": "^6.1.3", "react-virtualized": "^9.22.6", "rimraf": "^6.1.3", "rxjs": "8.0.0-alpha.14", - "sass": "^1.102.0", - "sass-embedded": "^1.100.0", + "sass": "^1.103.1", + "sass-embedded": "^1.103.1", "sax": "^1.6.1", "scheduler": "^0.27.0", "source-map-support": "^0.5.21", - "storybook": "10.5.5", - "style-dictionary": "5.5.0", + "storybook": "10.5.10", + "style-dictionary": "5.5.2", "stylelint": "^17.14.1", "stylelint-config-standard-scss": "^17.0.0", "stylelint-plugin-logical-css": "^2.1.0", "stylelint-scss": "^7.2.0", "svg-sprite": "^2.0.4", - "tdigest": "^0.1.2", + "tdigest": "^0.1.3", "tinycolor2": "^1.6.0", "typescript": "^6.0.2", - "vite": "^8.2.0", - "vitest": "^4.1.10", + "vite": "^8.2.2", + "vitest": "^4.1.11", "wait-on": "^9.1.0", "watcher": "^2.3.1", "workerpool": "^10.0.3", @@ -131,6 +131,6 @@ }, "dependencies": { "@penpot/ui": "link:packages/ui", - "react-aria-components": "^1.19.0" + "react-aria-components": "^1.20.0" } } diff --git a/frontend/packages/draft-js/index.js b/frontend/packages/draft-js/index.js index f02109f971..5372d7ee40 100644 --- a/frontend/packages/draft-js/index.js +++ b/frontend/packages/draft-js/index.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import pkg from "draft-js"; @@ -22,7 +22,8 @@ export const { } = pkg; import DraftPasteProcessor from 'draft-js/lib/DraftPasteProcessor.js'; -import {Map, OrderedSet} from "immutable"; +import Immutable from "immutable"; +const {Map, OrderedSet} = Immutable; function isDefined(v) { return v !== undefined && v !== null; diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json index d71c2bf0cf..682dfeb8d0 100644 --- a/frontend/packages/draft-js/package.json +++ b/frontend/packages/draft-js/package.json @@ -4,12 +4,11 @@ "description": "Penpot Draft-JS Wrapper", "main": "index.js", "type": "module", - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { - "draft-js": "penpot/draft-js.git#c58ebd9429a6359d72a88cff87e078aaf6fe285d", - "immutable": "^5.1.9" + "draft-js": "penpot/draft-js.git#c58ebd9429a6359d72a88cff87e078aaf6fe285d" }, "peerDependencies": { "react": ">=0.17.0", diff --git a/frontend/packages/mousetrap/index.js b/frontend/packages/mousetrap/index.js index 12bcbab1b9..4d0576e417 100644 --- a/frontend/packages/mousetrap/index.js +++ b/frontend/packages/mousetrap/index.js @@ -30,29 +30,29 @@ const globalDocument = globalThis?.document; * @type {Object} */ var _MAP = { - 8: 'backspace', - 9: 'tab', - 13: 'enter', - 16: 'shift', - 17: 'ctrl', - 18: 'alt', - 20: 'capslock', - 27: 'esc', - 32: 'space', - 33: 'pageup', - 34: 'pagedown', - 35: 'end', - 36: 'home', - 37: 'left', - 38: 'up', - 39: 'right', - 40: 'down', - 45: 'ins', - 46: 'del', - 91: 'meta', - 93: 'meta', - 224: 'meta', - 219: '219' + 8: "backspace", + 9: "tab", + 13: "enter", + 16: "shift", + 17: "ctrl", + 18: "alt", + 20: "capslock", + 27: "esc", + 32: "space", + 33: "pageup", + 34: "pagedown", + 35: "end", + 36: "home", + 37: "left", + 38: "up", + 39: "right", + 40: "down", + 45: "ins", + 46: "del", + 91: "meta", + 93: "meta", + 224: "meta", + 219: "219", }; /** @@ -64,22 +64,22 @@ var _MAP = { * @type {Object} */ var _KEYCODE_MAP = { - 106: '*', - 107: '+', - 109: '-', - 110: '.', - 111 : '/', - 186: ';', - 187: '=', - 188: ',', - 189: '-', - 190: '.', - 191: '/', - 192: '`', - 219: '[', - 220: '\\', - 221: ']', - 222: '\'' + 106: "*", + 107: "+", + 109: "-", + 110: ".", + 111: "/", + 186: ";", + 187: "=", + 188: ",", + 189: "-", + 190: ".", + 191: "/", + 192: "`", + 219: "[", + 220: "\\", + 221: "]", + 222: "'", }; /** @@ -93,25 +93,25 @@ var _KEYCODE_MAP = { * @type {Object} */ var _SHIFT_MAP = { - '~': '`', - '!': '1', - '@': '2', - '#': '3', - '$': '4', - '%': '5', - '^': '6', - '&': '7', - '*': '8', - '(': '9', - ')': '0', - '_': '-', - '+': '=', - ':': ';', - '\"': '\'', - '<': ',', - '>': '.', - '?': '/', - '|': '\\' + "~": "`", + "!": "1", + "@": "2", + "#": "3", + $: "4", + "%": "5", + "^": "6", + "&": "7", + "*": "8", + "(": "9", + ")": "0", + _: "-", + "+": "=", + ":": ";", + '\"': "'", + "<": ",", + ">": ".", + "?": "/", + "|": "\\", }; /** @@ -124,12 +124,12 @@ var _SHIFT_MAP = { var globalNavigator = globalThis.navigator; var _SPECIAL_ALIASES = { - 'option': 'alt', - 'command': 'meta', - 'return': 'enter', - 'escape': 'esc', - 'plus': '+', - 'mod': /Mac|iPod|iPhone|iPad/.test(globalNavigator?.platform) ? 'meta' : 'ctrl' + option: "alt", + command: "meta", + return: "enter", + escape: "esc", + plus: "+", + mod: /Mac|iPod|iPhone|iPad/.test(globalNavigator?.platform) ? "meta" : "ctrl", }; /** @@ -146,14 +146,13 @@ var _REVERSE_MAP; * programatically */ for (var i = 1; i < 20; ++i) { - _MAP[111 + i] = 'f' + i; + _MAP[111 + i] = "f" + i; } /** * loop through to map numbers on the numeric keypad */ for (i = 0; i <= 9; ++i) { - // This needs to use a string cause otherwise since 0 is falsey // mousetrap will never fire for numpad 0 pressed as part of a keydown // event. @@ -176,7 +175,7 @@ function _addEvent(object, type, callback) { return; } - object.attachEvent('on' + type, callback); + object.attachEvent("on" + type, callback); } /** @@ -186,17 +185,16 @@ function _addEvent(object, type, callback) { * @return {string} */ function _characterFromEvent(e) { - // Numpad digits as "num0".."num9" — keeps them separate from main-row bindings across NumLock states and event types. - if (e.code && e.code.indexOf('Numpad') === 0) { + if (e.code && e.code.indexOf("Numpad") === 0) { var suffix = e.code.substring(6); - if (suffix.length === 1 && suffix >= '0' && suffix <= '9') { - return 'num' + suffix; + if (suffix.length === 1 && suffix >= "0" && suffix <= "9") { + return "num" + suffix; } } // for keypress events we should return the character as is - if (e.type == 'keypress') { + if (e.type == "keypress") { var character = String.fromCharCode(e.which); // if the shift key is not pressed then it is safe to assume @@ -225,6 +223,9 @@ function _characterFromEvent(e) { } // if it is not in the special map + if (typeof e.key === "string") { + return e.key.toLowerCase(); + } // with keydown and keyup events the character seems to always // come in as an uppercase character whether you are pressing shift @@ -240,7 +241,7 @@ function _characterFromEvent(e) { * @returns {boolean} */ function _modifiersMatch(modifiers1, modifiers2) { - return modifiers1.sort().join(',') === modifiers2.sort().join(','); + return modifiers1.sort().join(",") === modifiers2.sort().join(","); } /** @@ -253,19 +254,19 @@ function _eventModifiers(e) { var modifiers = []; if (e.shiftKey) { - modifiers.push('shift'); + modifiers.push("shift"); } if (e.altKey) { - modifiers.push('alt'); + modifiers.push("alt"); } if (e.ctrlKey) { - modifiers.push('ctrl'); + modifiers.push("ctrl"); } if (e.metaKey) { - modifiers.push('meta'); + modifiers.push("meta"); } return modifiers; @@ -308,7 +309,7 @@ function _stopPropagation(e) { * @returns {boolean} */ function _isModifier(key) { - return key == 'shift' || key == 'ctrl' || key == 'alt' || key == 'meta'; + return key == "shift" || key == "ctrl" || key == "alt" || key == "meta"; } /** @@ -321,7 +322,6 @@ function _getReverseMap() { if (!_REVERSE_MAP) { _REVERSE_MAP = {}; for (var key in _MAP) { - // pull out the numeric keypad from here cause keypress should // be able to detect the keys from the character if (key > 95 && key < 112) { @@ -344,17 +344,16 @@ function _getReverseMap() { * @param {string=} action passed in */ function _pickBestAction(key, modifiers, action) { - // if no action was picked in we should try to pick the one // that we think would work best for this key if (!action) { - action = _getReverseMap()[key] ? 'keydown' : 'keypress'; + action = _getReverseMap()[key] ? "keydown" : "keypress"; } // modifier keys don't work as expected with keypress, // switch to keydown - if (action == 'keypress' && modifiers.length) { - action = 'keydown'; + if (action == "keypress" && modifiers.length) { + action = "keydown"; } return action; @@ -367,12 +366,12 @@ function _pickBestAction(key, modifiers, action) { * @return {Array} */ function _keysFromString(combination) { - if (combination === '+') { - return ['+']; + if (combination === "+") { + return ["+"]; } - combination = combination.replace(/\+{2}/g, '+plus'); - return combination.split('+'); + combination = combination.replace(/\+{2}/g, "+plus"); + return combination.split("+"); } /** @@ -403,9 +402,9 @@ function _getKeyInfo(combination, action) { // if this is not a keypress event then we should // be smart about using shift keys // this will only work for US keyboards however - if (action && action != 'keypress' && _SHIFT_MAP[key]) { + if (action && action != "keypress" && _SHIFT_MAP[key]) { key = _SHIFT_MAP[key]; - modifiers.push('shift'); + modifiers.push("shift"); } // if this key is a modifier then add it to the list of modifiers @@ -421,7 +420,7 @@ function _getKeyInfo(combination, action) { return { key: key, modifiers: modifiers, - action: action + action: action, }; } @@ -510,7 +509,7 @@ function Mousetrap(targetElement) { doNotReset = doNotReset || {}; var activeSequences = false, - key; + key; for (key in _sequenceLevels) { if (doNotReset[key]) { @@ -537,7 +536,14 @@ function Mousetrap(targetElement) { * @param {number=} level * @returns {Array} */ - function _getMatches(character, modifiers, e, sequenceName, combination, level) { + function _getMatches( + character, + modifiers, + e, + sequenceName, + combination, + level, + ) { var i; var callback; var matches = []; @@ -549,7 +555,7 @@ function Mousetrap(targetElement) { } // if a modifier key is coming up on its own we should allow it - if (action == 'keyup' && _isModifier(character)) { + if (action == "keyup" && _isModifier(character)) { modifiers = [character]; } @@ -560,7 +566,11 @@ function Mousetrap(targetElement) { // if a sequence name is not specified, but this is a sequence at // the wrong level then move onto the next match - if (!sequenceName && callback.seq && _sequenceLevels[callback.seq] != callback.level) { + if ( + !sequenceName && + callback.seq && + _sequenceLevels[callback.seq] != callback.level + ) { continue; } @@ -577,15 +587,20 @@ function Mousetrap(targetElement) { // chrome will not fire a keypress if meta or control is down // safari will fire a keypress if meta or meta+shift is down // firefox will fire a keypress if meta or control is down - if ((action == 'keypress' && !e.metaKey && !e.ctrlKey) || _modifiersMatch(modifiers, callback.modifiers)) { - + if ( + (action == "keypress" && !e.metaKey && !e.ctrlKey) || + _modifiersMatch(modifiers, callback.modifiers) + ) { // when you bind a combination or sequence a second time it // should overwrite the first one. if a sequenceName or // combination is specified in this call it does just that // // @todo make deleting its own method? var deleteCombo = !sequenceName && callback.combo == combination; - var deleteSequence = sequenceName && callback.seq == sequenceName && callback.level == level; + var deleteSequence = + sequenceName && + callback.seq == sequenceName && + callback.level == level; if (deleteCombo || deleteSequence) { self._callbacks[character].splice(i, 1); } @@ -608,7 +623,6 @@ function Mousetrap(targetElement) { * @returns void */ function _fireCallback(callback, e, combo, sequence) { - // if this event should not happen stop here if (self.stopCallback(e, e.target || e.srcElement, combo, sequence)) { return; @@ -628,7 +642,7 @@ function Mousetrap(targetElement) { * @param {Event} e * @returns void */ - self._handleKey = function(character, modifiers, e) { + self._handleKey = function (character, modifiers, e) { var callbacks = _getMatches(character, modifiers, e); var i; var doNotReset = {}; @@ -644,14 +658,12 @@ function Mousetrap(targetElement) { // loop through matching callbacks for this key event for (i = 0; i < callbacks.length; ++i) { - // fire for all sequence callbacks // this is because if for example you have multiple sequences // bound such as "g i" and "g t" they both need to fire the // callback for matching g cause otherwise you can only ever // match the first one if (callbacks[i].seq) { - // only fire callbacks for the maxLevel to prevent // subsequences from also firing // @@ -668,7 +680,12 @@ function Mousetrap(targetElement) { // keep a list of which sequences were matches for later doNotReset[callbacks[i].seq] = 1; - _fireCallback(callbacks[i].callback, e, callbacks[i].combo, callbacks[i].seq); + _fireCallback( + callbacks[i].callback, + e, + callbacks[i].combo, + callbacks[i].seq, + ); continue; } @@ -700,12 +717,16 @@ function Mousetrap(targetElement) { // // we ignore keypresses in a sequence that directly follow a keydown // for the same character - var ignoreThisKeypress = e.type == 'keypress' && _ignoreNextKeypress; - if (e.type == _nextExpectedAction && !_isModifier(character) && !ignoreThisKeypress) { + var ignoreThisKeypress = e.type == "keypress" && _ignoreNextKeypress; + if ( + e.type == _nextExpectedAction && + !_isModifier(character) && + !ignoreThisKeypress + ) { _resetSequences(doNotReset); } - _ignoreNextKeypress = processedSequenceCallback && e.type == 'keydown'; + _ignoreNextKeypress = processedSequenceCallback && e.type == "keydown"; }; /** @@ -715,10 +736,9 @@ function Mousetrap(targetElement) { * @returns void */ function _handleKeyEvent(e) { - // normalize e.which for key events // @see http://stackoverflow.com/questions/4285627/javascript-keycode-vs-charcode-utter-confusion - if (typeof e.which !== 'number') { + if (typeof e.which !== "number") { e.which = e.keyCode; } @@ -730,7 +750,7 @@ function Mousetrap(targetElement) { } // need to use === for the character check because the character can be 0 - if (e.type == 'keyup' && _ignoreNextKeyup === character) { + if (e.type == "keyup" && _ignoreNextKeyup === character) { _ignoreNextKeyup = false; return; } @@ -761,7 +781,6 @@ function Mousetrap(targetElement) { * @returns void */ function _bindSequence(combo, keys, callback, action) { - // start off by adding a sequence level record for this combination // and setting the level to 0 _sequenceLevels[combo] = 0; @@ -774,7 +793,7 @@ function Mousetrap(targetElement) { * @returns {Function} */ function _increaseSequence(nextAction) { - return function() { + return function () { _nextExpectedAction = nextAction; ++_sequenceLevels[combo]; _resetSequenceTimer(); @@ -794,7 +813,7 @@ function Mousetrap(targetElement) { // we should ignore the next key up if the action is key down // or keypress. this is so if you finish a sequence and // release the key the final key will not trigger a keyup - if (action !== 'keyup') { + if (action !== "keyup") { _ignoreNextKeyup = _characterFromEvent(e); } @@ -814,7 +833,9 @@ function Mousetrap(targetElement) { // ones are better suited to the key provided for (var i = 0; i < keys.length; ++i) { var isFinal = i + 1 === keys.length; - var wrappedCallback = isFinal ? _callbackAndReset : _increaseSequence(action || _getKeyInfo(keys[i + 1]).action); + var wrappedCallback = isFinal + ? _callbackAndReset + : _increaseSequence(action || _getKeyInfo(keys[i + 1]).action); _bindSingle(keys[i], wrappedCallback, action, combo, i); } } @@ -829,15 +850,21 @@ function Mousetrap(targetElement) { * @param {number=} level - what part of the sequence the command is * @returns void */ - function _bindSingle(combination, callback, action, sequenceName, level, overwrite) { - + function _bindSingle( + combination, + callback, + action, + sequenceName, + level, + overwrite, + ) { // store a direct mapped reference for use with Mousetrap.trigger - self._directMap[combination + ':' + action] = callback; + self._directMap[combination + ":" + action] = callback; // make sure multiple spaces in a row become a single space - combination = combination.replace(/\s+/g, ' '); + combination = combination.replace(/\s+/g, " "); - var sequence = combination.split(' '); + var sequence = combination.split(" "); var info; // if this pattern is a sequence of keys then run through this method @@ -855,7 +882,14 @@ function Mousetrap(targetElement) { // remove an existing match if there is one if (overwrite) { - _getMatches(info.key, info.modifiers, {type: info.action}, sequenceName, combination, level); + _getMatches( + info.key, + info.modifiers, + { type: info.action }, + sequenceName, + combination, + level, + ); } // add this call back to the array @@ -864,13 +898,13 @@ function Mousetrap(targetElement) { // // this is important because the way these are processed expects // the sequence ones to come first - self._callbacks[info.key][sequenceName ? 'unshift' : 'push']({ + self._callbacks[info.key][sequenceName ? "unshift" : "push"]({ callback: callback, modifiers: info.modifiers, action: info.action, seq: sequenceName, level: level, - combo: combination + combo: combination, }); } @@ -882,16 +916,23 @@ function Mousetrap(targetElement) { * @param {string|undefined} action * @returns void */ - self._bindMultiple = function(combinations, callback, action, overwrite) { + self._bindMultiple = function (combinations, callback, action, overwrite) { for (var i = 0; i < combinations.length; ++i) { - _bindSingle(combinations[i], callback, action, undefined, undefined, overwrite); + _bindSingle( + combinations[i], + callback, + action, + undefined, + undefined, + overwrite, + ); } }; if (targetElement) { - _addEvent(targetElement, 'keypress', _handleKeyEvent); - _addEvent(targetElement, 'keydown', _handleKeyEvent); - _addEvent(targetElement, 'keyup', _handleKeyEvent); + _addEvent(targetElement, "keypress", _handleKeyEvent); + _addEvent(targetElement, "keydown", _handleKeyEvent); + _addEvent(targetElement, "keyup", _handleKeyEvent); } } @@ -909,7 +950,7 @@ function Mousetrap(targetElement) { * @param {string=} action - 'keypress', 'keydown', or 'keyup' * @returns void */ -Mousetrap.prototype.bind = function(keys, callback, action, overwrite) { +Mousetrap.prototype.bind = function (keys, callback, action, overwrite) { var self = this; keys = keys instanceof Array ? keys : [keys]; self._bindMultiple.call(self, keys, callback, action, overwrite); @@ -933,9 +974,9 @@ Mousetrap.prototype.bind = function(keys, callback, action, overwrite) { * @param {string} action * @returns void */ -Mousetrap.prototype.unbind = function(keys, action) { +Mousetrap.prototype.unbind = function (keys, action) { var self = this; - return self.bind.call(self, keys, function() {}, action); + return self.bind.call(self, keys, function () {}, action); }; /** @@ -945,10 +986,10 @@ Mousetrap.prototype.unbind = function(keys, action) { * @param {string=} action * @returns void */ -Mousetrap.prototype.trigger = function(keys, action) { +Mousetrap.prototype.trigger = function (keys, action) { var self = this; - if (self._directMap[keys + ':' + action]) { - self._directMap[keys + ':' + action]({}, keys); + if (self._directMap[keys + ":" + action]) { + self._directMap[keys + ":" + action]({}, keys); } return self; }; @@ -960,7 +1001,7 @@ Mousetrap.prototype.trigger = function(keys, action) { * * @returns void */ -Mousetrap.prototype.reset = function() { +Mousetrap.prototype.reset = function () { var self = this; self._callbacks = {}; self._directMap = {}; @@ -978,20 +1019,20 @@ Mousetrap.prototype.stopCallback = function (e, element, combo) { // if the element has the data attribute "mousetrap-dont-stop" then no need // to stop. It should be used like <div data-mousetrap-dont-stop>...</div> // or :div {:data-mousetrap-dont-stop true} - if ('mousetrapDontStop' in element.dataset) { - return false + if ("mousetrapDontStop" in element.dataset) { + return false; } - if ((' ' + element.className + ' ').indexOf(' mousetrap ') > -1) { + if ((" " + element.className + " ").indexOf(" mousetrap ") > -1) { return false; } // Keyup events need to be dispatched always. Otherwise some events can be stuck - if (e.type == 'keyup') { + if (e.type == "keyup") { return false; } - if ('composedPath' in e && typeof e.composedPath === 'function') { + if ("composedPath" in e && typeof e.composedPath === "function") { // For open shadow trees, update `element` so that the following check works. const initialEventTarget = e.composedPath()[0]; if (initialEventTarget !== e.target) { @@ -1000,20 +1041,22 @@ Mousetrap.prototype.stopCallback = function (e, element, combo) { } // stop for input, select, textarea and button - const shouldStop = element.tagName == "INPUT" || - element.tagName == "SELECT" || - element.tagName == "TEXTAREA" || - (element.tagName == "BUTTON" && combo.includes("tab")) || - (element.contentEditable && (element.contentEditable == "true" || element.contentEditable === "plaintext-only")); + const shouldStop = + element.tagName == "INPUT" || + element.tagName == "SELECT" || + element.tagName == "TEXTAREA" || + (element.tagName == "BUTTON" && combo.includes("tab")) || + (element.contentEditable && + (element.contentEditable == "true" || + element.contentEditable === "plaintext-only")); return shouldStop; -} - +}; /** * exposes _handleKey publicly so it can be overwritten by extensions */ -Mousetrap.prototype.handleKey = function() { +Mousetrap.prototype.handleKey = function () { var self = this; return self._handleKey.apply(self, arguments); }; @@ -1028,7 +1071,7 @@ export function addKeycodes(object) { } } _REVERSE_MAP = null; -}; +} /** * Init the global mousetrap functions diff --git a/frontend/packages/mousetrap/package.json b/frontend/packages/mousetrap/package.json index 4b20059b0f..ece42204ef 100644 --- a/frontend/packages/mousetrap/package.json +++ b/frontend/packages/mousetrap/package.json @@ -4,7 +4,7 @@ "description": "Simple library for handling keyboard shortcuts", "main": "index.js", "type": "module", - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "author": "Craig Campbell", "license": "Apache-2.0 WITH LLVM-exception" } diff --git a/frontend/packages/tokenscript/package.json b/frontend/packages/tokenscript/package.json index 9c4ed11113..4689f02e86 100644 --- a/frontend/packages/tokenscript/package.json +++ b/frontend/packages/tokenscript/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "type": "module", - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { diff --git a/frontend/packages/ui/package.json b/frontend/packages/ui/package.json index 1fca7c11a8..131a0e6d85 100644 --- a/frontend/packages/ui/package.json +++ b/frontend/packages/ui/package.json @@ -20,24 +20,24 @@ "devDependencies": { "@babel/core": "^8.0.1", "@babel/preset-react": "^8.0.1", - "@storybook/react": "10.5.5", - "@storybook/react-vite": "10.5.5", + "@storybook/react": "10.5.10", + "@storybook/react-vite": "10.5.10", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.2", "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.4", - "@vitejs/plugin-react": "^6.0.5", + "@types/react-dom": "^19.2.5", + "@vitejs/plugin-react": "^6.1.0", "babel-plugin-react-compiler": "^1.0.0", "eslint-plugin-import": "2.32.0", "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.1.1", "react-compiler-runtime": "^1.0.0", - "storybook": "10.5.5", + "storybook": "10.5.10", "vite-plugin-dts": "^5.0.3" }, "dependencies": { - "react-aria-components": "^1.19.0" + "react-aria-components": "^1.20.0" }, "peerDependencies": { "react": ">=19.2", diff --git a/frontend/playwright/data/register/verify-token-email-verified.json b/frontend/playwright/data/register/verify-token-email-verified.json new file mode 100644 index 0000000000..347bc4057c --- /dev/null +++ b/frontend/playwright/data/register/verify-token-email-verified.json @@ -0,0 +1,28 @@ +{ + "~:iss": "~:verify-email", + "~:profile-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b", + "~:profile": { + "~:id": "~uc7ce0794-0992-8105-8004-38e630f29a9b", + "~:email": "foo@example.com", + "~:fullname": "Princesa Leia", + "~:auth-backend": "penpot", + "~:is-active": true, + "~:is-demo": false, + "~:is-muted": false, + "~:is-blocked": false, + "~:theme": "", + "~:default-team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d", + "~:default-project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:created-at": "~m1713533116365", + "~:modified-at": "~m1713533116365", + "~:props": { + "~:nudge": { + "~:big": 10, + "~:small": 1 + }, + "~:v2-info-shown": true, + "~:viewed-tutorial?": false, + "~:viewed-walkthrough?": false + } + } +} diff --git a/frontend/playwright/data/text-editor/get-file-fixed-size-text.json b/frontend/playwright/data/text-editor/get-file-fixed-size-text.json new file mode 100644 index 0000000000..5e88e658a2 --- /dev/null +++ b/frontend/playwright/data/text-editor/get-file-fixed-size-text.json @@ -0,0 +1,349 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type", + "text-editor/v2" + ] + }, + "~:team-id": "~u9e6e22b2-db76-81d6-8006-75d7cdbb8bad", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "Fixed size text", + "~:revn": 3, + "~:modified-at": "~m1753957736516", + "~:vern": 0, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2230", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content-v2", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content-v2", + "0004-clean-shadow-color", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0007-clear-invalid-strokes-and-fills-v2", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags" + ] + }, + "~:version": 67, + "~:project-id": "~u9e6e22b2-db76-81d6-8006-75d7cdc30669", + "~:created-at": "~m1753957644225", + "~:data": { + "~:pages": [ + "~u238a17e0-75ff-8075-8006-934586ea2231" + ], + "~:pages-index": { + "~u238a17e0-75ff-8075-8006-934586ea2231": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0.0, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0.0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1.0, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~ucc6f0580-449c-8019-8006-9345db077fa0" + ] + } + }, + "~ucc6f0580-449c-8019-8006-9345db077fa0": { + "~#shape": { + "~:y": 150, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:content": { + "~:type": "root", + "~:key": "1s4am1jl24s", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "13p0zwl2yhc", + "~:font-size": "14", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "Lorem ipsum" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "20hf3kmyoub", + "~:font-size": "14", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "Fixed text", + "~:width": 300, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 200, + "~:y": 150 + } + }, + { + "~#point": { + "~:x": 500, + "~:y": 150 + } + }, + { + "~#point": { + "~:x": 500, + "~:y": 350 + } + }, + { + "~#point": { + "~:x": 200, + "~:y": 350 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~ucc6f0580-449c-8019-8006-9345db077fa0", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:x": 200, + "~:selrect": { + "~#rect": { + "~:x": 200, + "~:y": 150, + "~:width": 300, + "~:height": 200, + "~:x1": 200, + "~:y1": 150, + "~:x2": 500, + "~:y2": 350 + } + }, + "~:flip-x": null, + "~:height": 200, + "~:flip-y": null + } + } + }, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2231", + "~:name": "Page 1" + } + }, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2230", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} \ No newline at end of file diff --git a/frontend/playwright/ui/pages/DashboardPage.js b/frontend/playwright/ui/pages/DashboardPage.js index 4dee04f18e..eb4bea0b9f 100644 --- a/frontend/playwright/ui/pages/DashboardPage.js +++ b/frontend/playwright/ui/pages/DashboardPage.js @@ -86,7 +86,7 @@ export class DashboardPage extends BaseWebSocketPage { this.searchInput = page.getByPlaceholder("Search…"); this.teamDropdown = this.sidebar.getByRole("button", { - name: "Your Penpot", + name: "Personal Projects", }); this.userAccount = this.sidebar.getByRole("button", { name: /Princesa Leia/, diff --git a/frontend/playwright/ui/pages/OnboardingPage.js b/frontend/playwright/ui/pages/OnboardingPage.js index bbf59bbab6..0fe447463e 100644 --- a/frontend/playwright/ui/pages/OnboardingPage.js +++ b/frontend/playwright/ui/pages/OnboardingPage.js @@ -7,7 +7,7 @@ export class OnboardingPage extends BaseWebSocketPage { } async fillOnboardingInputsStep1() { - await this.page.getByText("Personal").click(); + await this.page.getByText("Personal", { exact: true }).click(); await this.page.getByText("Select option").click(); await this.page.getByText("Product Management").click(); diff --git a/frontend/playwright/ui/pages/RegisterPage.js b/frontend/playwright/ui/pages/RegisterPage.js index 8d3633e678..ef43f56469 100644 --- a/frontend/playwright/ui/pages/RegisterPage.js +++ b/frontend/playwright/ui/pages/RegisterPage.js @@ -29,6 +29,42 @@ export class RegisterPage extends BasePage { ); } + /** + * Mocks a successful email-verification token exchange (the link the + * user clicks from the verification email) and every RPC the dashboard + * needs to render right after landing on it, so the flow can be + * exercised end-to-end without a real backend. + */ + async setupEmailVerificationSuccess() { + await this.mockConfigFlags(["disable-onboarding"]); + await this.mockRPC( + "verify-token", + "register/verify-token-email-verified.json", + ); + await this.mockRPCs({ + "get-teams": "logged-in-user/get-teams-default.json", + "get-font-variants?team-id=*": + "logged-in-user/get-font-variants-empty.json", + "get-projects?team-id=*": "logged-in-user/get-projects-default.json", + "get-team-members?team-id=*": + "logged-in-user/get-team-members-your-penpot.json", + "get-team-users?team-id=*": + "logged-in-user/get-team-users-single-user.json", + "get-unread-comment-threads?team-id=*": + "logged-in-user/get-team-users-single-user.json", + "get-team-recent-files?team-id=*": + "logged-in-user/get-team-recent-files-empty.json", + "get-profiles-for-file-comments": + "logged-in-user/get-profiles-for-file-comments-empty.json", + "get-builtin-templates": + "logged-in-user/get-built-in-templates-empty.json", + }); + } + + async goToVerifyToken(token = "verify-email-token") { + await this.page.goto(`/#/auth/verify-token?token=${token}`); + } + static async init(page) { await BasePage.init(page); } diff --git a/frontend/playwright/ui/pages/ShortcutsPage.js b/frontend/playwright/ui/pages/ShortcutsPage.js index 578311570e..d45bb1b970 100644 --- a/frontend/playwright/ui/pages/ShortcutsPage.js +++ b/frontend/playwright/ui/pages/ShortcutsPage.js @@ -318,7 +318,9 @@ export class ShortcutsPage extends BaseWebSocketPage { this.exportShortcuts(), ]); - expect(download.suggestedFilename()).toBe("penpot-shortcuts.json"); + expect(download.suggestedFilename()).toMatch( + /^penpot-shortcuts-Princesa_Leia-\d{4}-\d{2}-\d{2}\.json$/, + ); const path = await download.path(); const content = await readFile(path, "utf-8"); diff --git a/frontend/playwright/ui/pages/WorkspacePage.js b/frontend/playwright/ui/pages/WorkspacePage.js index f6b6a5a11a..d2953a2c71 100644 --- a/frontend/playwright/ui/pages/WorkspacePage.js +++ b/frontend/playwright/ui/pages/WorkspacePage.js @@ -600,6 +600,12 @@ export class WorkspacePage extends BaseWebSocketPage { .getByRole("button", { name: "Comments (C)" }) .click(clickOptions); } + + async toggleCommentsVisibilityFromMenu(clickOptions = {}) { + await this.page.getByRole("button", { name: "Main menu" }).click(); + await this.page.getByText("view").last().click(); + await this.page.locator("#file-menu-comments").click(clickOptions); + } } export default WorkspacePage; diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js index 74b83d1940..a85b70887e 100644 --- a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js @@ -7,6 +7,12 @@ test.beforeEach(async ({ page }) => { "enable-feature-render-wasm", "enable-render-wasm-dpr", ]); + // Opening a wasm workspace backfills :position-data for text shapes, persisting the file. + await WasmWorkspacePage.mockRPC( + page, + "update-file?id=*", + "text-editor/update-file.json", + ); }); test("Renders a file with basic shapes, boards and groups", async ({ diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js b/frontend/playwright/ui/render-wasm-specs/texts.spec.js index f660356d91..4122d7e307 100644 --- a/frontend/playwright/ui/render-wasm-specs/texts.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/texts.spec.js @@ -7,6 +7,12 @@ test.beforeEach(async ({ page }) => { "enable-feature-render-wasm", "enable-render-wasm-dpr", ]); + // Opening a wasm workspace backfills :position-data for text shapes, persisting the file. + await WasmWorkspacePage.mockRPC( + page, + "update-file?id=*", + "text-editor/update-file.json", + ); }); async function mockGetEmojiFont(workspace) { diff --git a/frontend/playwright/ui/specs/email-verification.spec.js b/frontend/playwright/ui/specs/email-verification.spec.js new file mode 100644 index 0000000000..6be76653ce --- /dev/null +++ b/frontend/playwright/ui/specs/email-verification.spec.js @@ -0,0 +1,45 @@ +import { test, expect } from "@playwright/test"; +import { RegisterPage } from "../pages/RegisterPage"; + +// Regression test for the bug where a freshly verified account (whose +// profile never had a theme persisted) ended up with an empty string as +// its theme instead of falling back to the dark default: the workspace +// switched to light mode and Settings > UI Theme showed a blank field. + +test.beforeEach(async ({ page }) => { + await RegisterPage.initWithLoggedOutUser(page); +}); + +test.describe("Email verification", () => { + test("Newly verified account defaults to the dark theme", async ({ + page, + }) => { + const registerPage = new RegisterPage(page); + await registerPage.setupEmailVerificationSuccess(); + + await registerPage.goToVerifyToken(); + await page.waitForURL("**/dashboard/**"); + + // `default` is the body class applied for dark theme, `light` for + // light theme (see app.util.theme/set-color-scheme). + await expect(page.locator("body")).toHaveClass(/default/); + await expect(page.locator("body")).not.toHaveClass(/light/); + }); + + test("Settings > UI Theme shows Penpot Dark (default) selected, not blank", async ({ + page, + }) => { + const registerPage = new RegisterPage(page); + await registerPage.setupEmailVerificationSuccess(); + + await registerPage.goToVerifyToken(); + await page.waitForURL("**/dashboard/**"); + + await page.goto("/#/settings/options"); + + // The language select is the first combobox on the page, the theme + // select is the second one. + const themeSelect = page.getByRole("combobox").nth(1); + await expect(themeSelect).toHaveText("Penpot Dark (default)"); + }); +}); diff --git a/frontend/playwright/ui/specs/text-editor-v3.spec.js b/frontend/playwright/ui/specs/text-editor-v3.spec.js index 53b439ab19..21d7d97909 100644 --- a/frontend/playwright/ui/specs/text-editor-v3.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v3.spec.js @@ -9,7 +9,9 @@ const FILE = { test.beforeEach(async ({ page }) => { await WasmWorkspacePage.init(page); // WASM_FLAGS already enables render-wasm; add the WASM text editor on top. - await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-wasm"]); + await WasmWorkspacePage.mockConfigFlags(page, [ + "enable-feature-text-editor-wasm", + ]); }); async function openEditorAndSelectAll(workspace) { @@ -21,13 +23,56 @@ async function openEditorAndSelectAll(workspace) { await workspace.page.keyboard.press("ControlOrMeta+a"); } + +test("Typography at a collapsed caret only styles newly typed text", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + const fontSize = workspace.textEditor.fontSize; + const editorInput = page.locator("#text-editor-wasm-input"); + + // Draw a text box, focus it, and type some text; the caret ends up collapsed + // after it. + await workspace.createTextShape(200, 150, 460, 260); + await workspace.clickAt(210, 160); + await expect(editorInput).toBeFocused(); + await page.keyboard.type("ab"); + + const originalSize = await fontSize.inputValue(); + const newSize = String(Number(originalSize) + 20); + + // Change the font size with a collapsed caret. This must not restyle the + // existing text; it is stashed as a pending style for the next input. Focus + // returns to the editor once the sidebar input blurs. + await workspace.textEditor.changeFontSize(newSize); + await expect(editorInput).toBeFocused(); + + // Typing now adopts the pending size as its own span. + await page.keyboard.type("X"); + + // The just-typed "X" carries the new size... + await page.keyboard.press("Shift+ArrowLeft"); + await expect(fontSize).toHaveValue(newSize); + + // ...while the pre-existing "ab" keeps the original size (the bug applied the + // change to the whole shape instead). + await page.keyboard.press("Home"); + await page.keyboard.press("Shift+ArrowRight"); + await page.keyboard.press("Shift+ArrowRight"); + await expect(fontSize).toHaveValue(originalSize); +}); + test.describe("BUG 10502 - Mixed families and variants", () => { - test("Multiple variants of the same font family", async ({ - page, - }) => { + test("Multiple variants of the same font family", async ({ page }) => { const workspace = new WasmWorkspacePage(page, { textEditor: true }); await workspace.setupEmptyFile(); - await workspace.mockGetFile("text-editor/get-file-10502-mixed-variants.json"); + await workspace.mockGetFile( + "text-editor/get-file-10502-mixed-variants.json", + ); await workspace.goToWorkspace(FILE); await workspace.waitForFirstRender(); @@ -47,10 +92,14 @@ test.describe("BUG 10502 - Mixed families and variants", () => { await expect(fontVariant).toHaveText("--"); }); - test("Mixed font families appear as such in the dropdown", async ({ page }) => { + test("Mixed font families appear as such in the dropdown", async ({ + page, + }) => { const workspace = new WasmWorkspacePage(page, { textEditor: true }); await workspace.setupEmptyFile(); - await workspace.mockGetFile("text-editor/get-file-10502-mixed-families.json"); + await workspace.mockGetFile( + "text-editor/get-file-10502-mixed-families.json", + ); // Serve a stand-in TTF for Sora so the render doesn't wait on a real fetch. // Glyphs are irrelevant here: the assertion only inspects the sidebar. await workspace.mockGoogleFont("sora", "render-wasm/assets/ebgaramond.ttf"); @@ -108,6 +157,69 @@ test.describe("BUG 10530 - Empty text box left behind when leaving the editor", }); }); +test.describe("BUG 11083 - Changing typography must not quit the editor", () => { + test("Changing a numeric input must not quit the editor", async ({ + page, + }) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + const layerRows = workspace.layers.getByTestId("layer-row"); + + // Draw an empty text box and, without typing anything, change the font size. + await workspace.createTextShape(200, 150, 320, 210); + await expect(layerRows).toHaveCount(1); + + await workspace.textEditor.changeFontSize(24); + + // The shape is not deleted and the editor is still mounted. + await expect(layerRows).toHaveCount(1); + await expect(page.getByTestId("text-editor")).toBeVisible(); + + // The edition survives, so we can click back into the box and keep typing. + await workspace.clickAt(210, 160); + await page.keyboard.type("hello"); + await workspace.textEditor.stopEditing(); + + await layerRows.first().click(); + await workspace.waitForSelectedShapeName("hello"); + }); + + test("Opening the font family selector must not quit the editor", async ({ + page, + }) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + const layerRows = workspace.layers.getByTestId("layer-row"); + + // Draw an empty text box and, without typing anything, open the font family + // selector + await workspace.createTextShape(200, 150, 320, 210); + await expect(layerRows).toHaveCount(1); + + await workspace.rightSidebar.getByTitle("Font Family").click(); + + // The shape is not deleted and the editor is still mounted. + await expect(layerRows).toHaveCount(1); + await expect(page.getByTestId("text-editor")).toBeVisible(); + + // The edition survives, so we can click back into the box and keep typing. + await workspace.clickAt(210, 160); + await page.keyboard.type("hello"); + await workspace.textEditor.stopEditing(); + + await layerRows.first().click(); + await workspace.waitForSelectedShapeName("hello"); + }); +}); + + + test("BUG 10467 - Auto-width text captures every typed character", async ({ page, }) => { @@ -129,6 +241,63 @@ test("BUG 10467 - Auto-width text captures every typed character", async ({ await workspace.waitForSelectedShapeName("hello world"); }); +test.describe("BUG 10910 - Text is not replaced when there is a selection", () => { + // Non-ascii on purpose: selection offsets are counted in characters. + test("Typing over a selection replaces it", async ({ page }) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + await workspace.createAutoWidthTextShape(200, 150, "Añadir"); + + await page.keyboard.press("ControlOrMeta+a"); + await page.keyboard.type("nuevo"); + + await workspace.textEditor.stopEditing(); + + await workspace.layers.getByTestId("layer-row").first().click(); + await workspace.waitForSelectedShapeName("nuevo"); + }); + + test("Typing over a selection that contains emoji replaces it", async ({ + page, + }) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + await workspace.createAutoWidthTextShape(200, 150, "Hola 😀"); + + await page.keyboard.press("ControlOrMeta+a"); + await page.keyboard.type("ok"); + + await workspace.textEditor.stopEditing(); + + await workspace.layers.getByTestId("layer-row").first().click(); + await workspace.waitForSelectedShapeName("ok"); + }); + + test("Backspace deletes the selection", async ({ page }) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + await workspace.createAutoWidthTextShape(200, 150, "Añadir texto"); + + await page.keyboard.press("ControlOrMeta+a"); + await page.keyboard.press("Backspace"); + await page.keyboard.type("ok"); + + await workspace.textEditor.stopEditing(); + + await workspace.layers.getByTestId("layer-row").first().click(); + await workspace.waitForSelectedShapeName("ok"); + }); +}); + test("BUG 10531 - Entering the editor auto-selects the whole text", async ({ page, }) => { @@ -147,9 +316,94 @@ test("BUG 10531 - Entering the editor auto-selects the whole text", async ({ await workspace.copy("keyboard"); // Assert the text was copied correctly - const copiedText = await page.evaluate(() => - navigator.clipboard.readText(), - ); + const copiedText = await page.evaluate(() => navigator.clipboard.readText()); expect(copiedText).toBe("Lorem ipsum"); }); +test.describe("BUG 10934 - Double-clicking a text side handle sets auto-size", () => { + // Sets up the workspace and loads a text shape whose size is larger than its text + async function setupFixedSizeText(page) { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + // Enable token inputs so they use the new component with accessible DOM + await workspace.mockConfigFlags(["enable-feature-token-input"]); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("text-editor/get-file-fixed-size-text.json"); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + // Select the text and zoom to fit, so it is fully visible in the viewport + await workspace.clickLeafLayer("Fixed text"); + await page.keyboard.press("Shift+1"); + await workspace.waitForIdle(); + + return workspace; + } + + async function doubleClickSideHandle(workspace, position) { + const handle = workspace.viewport.getByTestId( + `resize-side-handler-${position}`, + ); + await handle.waitFor(); + const box = await handle.boundingBox(); + await workspace.page.mouse.dblclick( + box.x + box.width / 2, + box.y + box.height / 2, + ); + } + + function measureInput(workspace, name) { + return workspace.rightSidebar + .getByRole("region", { name: "shape-measures-section" }) + .getByRole("textbox", { name, exact: true }); + } + + test("Double-clicking the right handle switches to auto-width", async ({ + page, + }) => { + const workspace = await setupFixedSizeText(page); + + const widthInput = workspace.rightSidebar + .getByRole("region", { name: "shape-measures-section" }) + .getByRole("textbox", { name: "Width", exact: true }); + const initialWidth = Number(await widthInput.inputValue()); + + await doubleClickSideHandle(workspace, "right"); + + // Assert auto-width is selected and that the width has shrunk. The resize + // is debounced, so poll the value (auto-retrying) rather than reading once. + await expect( + workspace.rightSidebar.getByRole("button", { + name: "Auto width", + pressed: true, + }), + ).toBeVisible(); + await expect + .poll(async () => Number(await widthInput.inputValue())) + .toBeLessThan(initialWidth); + }); + + test("Double-clicking the bottom handle switches to auto-height", async ({ + page, + }) => { + const workspace = await setupFixedSizeText(page); + + const heightInput = workspace.rightSidebar + .getByRole("region", { name: "shape-measures-section" }) + .getByRole("textbox", { name: "Height", exact: true }); + const initialHeight = Number(await heightInput.inputValue()); + + await doubleClickSideHandle(workspace, "bottom"); + + // Assert auto-height is selected and that the height has shrunk. The resize + // is debounced, so poll the value (auto-retrying) rather than reading once. + await expect( + workspace.rightSidebar.getByRole("button", { + name: "Auto height", + pressed: true, + }), + ).toBeVisible(); + await expect + .poll(async () => Number(await heightInput.inputValue())) + .toBeLessThan(initialHeight); + }); +}); diff --git a/frontend/playwright/ui/specs/tokens/crud.spec.js b/frontend/playwright/ui/specs/tokens/crud.spec.js index 4ba8d7fd49..35922e0af1 100644 --- a/frontend/playwright/ui/specs/tokens/crud.spec.js +++ b/frontend/playwright/ui/specs/tokens/crud.spec.js @@ -569,7 +569,7 @@ test.describe("Tokens - creation", () => { }); await selectDropdown.click(); - const fontOption = tokensUpdateCreateModal.getByText("ABeeZee"); + const fontOption = tokensUpdateCreateModal.getByRole('img', { name: 'ABeeZee' }) await expect(fontOption).toBeVisible(); await fontOption.click(); @@ -583,7 +583,7 @@ test.describe("Tokens - creation", () => { name: "Search font", }); await searchField.fill("alme"); - const fontOption2 = tokensUpdateCreateModal.getByText("Almendra Display"); + const fontOption2 = tokensUpdateCreateModal.getByRole('img', {name: "Almendra Display"}); await expect(fontOption2).toBeVisible(); await fontOption2.click(); @@ -1521,7 +1521,7 @@ test.describe("Tokens - creation", () => { }); await selectDropdown.click(); - const fontOption = tokensUpdateCreateModal.getByText("ABeeZee"); + const fontOption = tokensUpdateCreateModal.getByRole("img", {name: "ABeeZee"}); await expect(fontOption).toBeVisible(); await fontOption.click(); @@ -1536,7 +1536,7 @@ test.describe("Tokens - creation", () => { name: "Search font", }); await searchField.fill("alme"); - const fontOption2 = tokensUpdateCreateModal.getByText("Almendra Display"); + const fontOption2 = tokensUpdateCreateModal.getByRole("img", {name: "Almendra Display"}); await expect(fontOption2).toBeVisible(); await fontOption2.click(); await expect( diff --git a/frontend/playwright/ui/specs/workspace-comments.spec.js b/frontend/playwright/ui/specs/workspace-comments.spec.js index 8cc3cbe203..735b10b044 100644 --- a/frontend/playwright/ui/specs/workspace-comments.spec.js +++ b/frontend/playwright/ui/specs/workspace-comments.spec.js @@ -35,3 +35,64 @@ test("Group bubbles when zooming out if they overlap", async ({ page }) => { /unread/, ); }); + +test("Opening the Comments section only temporarily overrides a disabled global comments setting", async ({ + page, +}) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.setupFileWithComments(); + await workspacePage.goToWorkspace(); + + const bubble = page.getByTestId("floating-thread-bubble-1"); + + // "Display comments" is enabled by default, so the bubble is already visible. + await expect(bubble).toBeVisible(); + + // Turn the global "Display comments" setting off from the main menu. + await workspacePage.toggleCommentsVisibilityFromMenu(); + await expect(bubble).toBeHidden(); + + // Opening the Comments section shows comments regardless of the global setting. + await workspacePage.showComments(); + await expect(bubble).toBeVisible(); + + // Closing the Comments section falls back to the (still disabled) global setting. + await workspacePage.showComments(); + await expect(bubble).toBeHidden(); + + // The global setting itself must be untouched by opening/closing the section. + await page.getByRole("button", { name: "Main menu" }).click(); + await page.getByText("view").last().click(); + await expect(page.locator("#file-menu-comments")).toContainText( + "Show comments", + ); + await page.keyboard.press("Escape"); +}); + +test("Comments stay visible through opening and closing the Comments section when the global setting is enabled", async ({ + page, +}) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.setupFileWithComments(); + await workspacePage.goToWorkspace(); + + const bubble = page.getByTestId("floating-thread-bubble-1"); + + // "Display comments" is enabled by default. + await expect(bubble).toBeVisible(); + + await workspacePage.showComments(); + await expect(bubble).toBeVisible(); + + await workspacePage.showComments(); + await expect(bubble).toBeVisible(); + + await page.getByRole("button", { name: "Main menu" }).click(); + await page.getByText("view").last().click(); + await expect(page.locator("#file-menu-comments")).toContainText( + "Hide comments", + ); + await page.keyboard.press("Escape"); +}); diff --git a/frontend/playwright/ui/specs/workspace-modifers.spec.js b/frontend/playwright/ui/specs/workspace-modifers.spec.js index bbea6199f8..1fc688fbeb 100644 --- a/frontend/playwright/ui/specs/workspace-modifers.spec.js +++ b/frontend/playwright/ui/specs/workspace-modifers.spec.js @@ -233,5 +233,6 @@ test("BUG 13755 - Fix problem with text change modiifers", async ({ page }) => { name: "Width", exact: true, }); - await expect(widthInput).toHaveValue("23"); + // WASM auto-width includes the HTML paragraph-set 1px right margin. + await expect(widthInput).toHaveValue("24"); }); diff --git a/frontend/playwright/ui/specs/workspace-texts-typography-persist.spec.js b/frontend/playwright/ui/specs/workspace-texts-typography-persist.spec.js new file mode 100644 index 0000000000..d9b6c98c02 --- /dev/null +++ b/frontend/playwright/ui/specs/workspace-texts-typography-persist.spec.js @@ -0,0 +1,110 @@ +import { test, expect } from "@playwright/test"; +import { readFile } from "node:fs/promises"; +import { WasmWorkspacePage } from "../pages/WasmWorkspacePage"; + +// --------------------------------------------------------------------------- +// BUG 10925 - Font family typography asset must not persist across files in +// newly created text layers. +// +// `save-font` writes the current font (plus the typography refs of the edited +// shape, when it uses one) into the session-global `:workspace-global +// :default-font`. That state is what seeds the content of brand-new text +// shapes via `v2-default-text-content`. Because it is session-global it +// survives a file switch, so a text created in file B could end up referencing +// a typography asset that only exists in file A (see workspace/texts.cljs +// save-font and workspace.cljs initialize/finalize-workspace). +// +// This E2E reproduces the leak faithfully in a single SPA session: +// 1. Open file A (has a text shape linked to a typography asset). +// 2. Change a font attribute on that shape (triggers `emit-update!` -> +// `save-font` with the current text-node attrs, typography refs included). +// 3. Switch to file B (same session, fragment navigation keeps JS state). +// 4. Create a brand-new text layer in file B. +// 5. Assert the new text uses the DEFAULT Penpot font ("Source Sans Pro"), +// not the typography font-family carried over from file A. +// --------------------------------------------------------------------------- + +const FILE_A = { + id: "1062e0a0-8fe0-80ae-8007-e70b4993f5ef", + pageId: "1062e0a0-8fe0-80ae-8007-e70b4993f5f0", + // "Text with typography asset one" carries a ref to in-file typography whose + // font-family is "IM Fell French Canon SC" (multiselection-typography.json). +}; + +const FILE_B = { + id: "434b0541-fa2f-802f-8006-59827d964a9b", + pageId: "434b0541-fa2f-802f-8006-59827d964a9c", + // render-wasm/get-file-text-custom-fonts.json - a mostly empty file whose + // only text uses the default font (no typography asset). +}; + +async function serveTwoFiles(page) { + const fileABody = await readFile( + "playwright/data/workspace/multiselection-typography.json", + "utf-8", + ); + const fileBBody = await readFile( + "playwright/data/render-wasm/get-file-text-custom-fonts.json", + "utf-8", + ); + + // Dispatch on the `id` query param of the `get-file` RPC so each file gets + // its own fixture while keeping a single SPA session alive. + await page.route(/get\-file\?/, (route) => { + const url = new URL(route.request().url()); + const fileId = url.searchParams.get("id"); + const body = fileId === FILE_A.id ? fileABody : fileBBody; + return route.fulfill({ + status: 200, + contentType: "application/transit+json", + body, + }); + }); +} + +test.beforeEach(async ({ page }) => { + await WasmWorkspacePage.init(page); + // WASM_FLAGS already enables the v2 text editor / render-wasm. Add the WASM + // text editor on top so typography styles are read through the current text + // values path. + await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-wasm"]); +}); + +test("BUG 10925 - typography font does not leak into new text in a different file", async ({ page }) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.mockRPC( + "get-font-variants?team-id=*", + "render-wasm/get-font-variants-custom-fonts.json", + ); + + await serveTwoFiles(page); + + // ---- File A: select the text linked to a typography and change a font ---- + await workspace.goToWorkspace({ fileId: FILE_A.id, pageId: FILE_A.pageId }); + await workspace.waitForFirstRender(); + await workspace.doubleClickLeafLayer("Text with typography asset one"); + await workspace.textEditor.startEditing(); + + // Changing a font attribute triggers save-font with the current text-node + // attrs (including the typography refs) storing them into default-font. + await workspace.textEditor.changeFontSize(24); + await workspace.textEditor.stopEditing(); + + // ---- File B: same SPA session, switch to a file with no typography ---- + await workspace.goToWorkspace({ fileId: FILE_B.id, pageId: FILE_B.pageId }); + await workspace.waitForFirstRender(); + + // Create a brand-new text layer in file B and query its font-family. + await workspace.createTextShape(100, 100, 300, 200, "hello"); + await workspace.textEditor.stopEditing(); + await workspace.clickLeafLayer("hello"); + await workspace.textEditor.startEditing(); + await workspace.page.keyboard.press("ControlOrMeta+a"); + + const fontFamily = workspace.rightSidebar.getByTitle("Font Family"); + await expect(fontFamily).toContainText("Source Sans Pro"); + // The custom typography family from file A (IM Fell French Canon SC) must NOT + // be carried over. + await expect(fontFamily).not.toContainText("IM Fell"); +}); \ No newline at end of file diff --git a/frontend/playwright/ui/specs/workspace.spec.js b/frontend/playwright/ui/specs/workspace.spec.js index d7281244c7..702b3b3aef 100644 --- a/frontend/playwright/ui/specs/workspace.spec.js +++ b/frontend/playwright/ui/specs/workspace.spec.js @@ -171,10 +171,12 @@ test("Selection size badge shows dimensions for path shapes", async ({ page }) = // Workaround: hover viewport first to avoid nil mouse position crash await workspacePage.viewport.hover(); - // Draw a path + // Draw a path with two segments; a single straight segment shows + // endpoint controls instead of the size badge await workspacePage.pathButton.click(); await workspacePage.clickAt(779, 163); await workspacePage.clickAt(951, 258); + await workspacePage.clickAt(1050, 163); // Finish drawing (commits path, path enters edition mode) await page.keyboard.press("Escape"); @@ -187,6 +189,36 @@ test("Selection size badge shows dimensions for path shapes", async ({ page }) = await expect(badgeText).toHaveText(/\d+\.?\d* x \d+\.?\d*/); }); +test("Selection size badge is hidden for straight line paths", async ({ + page, +}) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.mockRPC( + "update-file?id=*", + "workspace/update-file-empty.json", + ); + + await workspacePage.goToWorkspace(); + + // Workaround: hover viewport first to avoid nil mouse position crash + await workspacePage.viewport.hover(); + + // Draw a path with a single straight segment + await workspacePage.pathButton.click(); + await workspacePage.clickAt(779, 163); + await workspacePage.clickAt(951, 258); + + // Finish drawing (commits path, path enters edition mode) + await page.keyboard.press("Escape"); + + // Exit edition mode (path stays selected) + await page.keyboard.press("Escape"); + + await expect(page.locator(".line-controls")).toBeVisible(); + await expect(page.locator(".selection-size-badge")).toHaveCount(0); +}); + test("User makes a group", async ({ page }) => { const workspacePage = new WasmWorkspacePage(page); await workspacePage.setupEmptyFile(); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 16975f3248..01866c4118 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- lockfileVersion: '9.0' settings: @@ -30,8 +131,8 @@ importers: specifier: link:packages/ui version: link:packages/ui react-aria-components: - specifier: ^1.19.0 - version: 1.19.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: ^1.20.0 + version: 1.20.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@penpot/draft-js': specifier: link:packages/draft-js @@ -58,53 +159,53 @@ importers: specifier: 1.62.1 version: 1.62.1 '@storybook/addon-docs': - specifier: 10.5.5 - version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + specifier: 10.5.10 + version: 10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) '@storybook/addon-themes': - specifier: 10.5.5 - version: 10.5.5(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + specifier: 10.5.10 + version: 10.5.10(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) '@storybook/addon-vitest': - specifier: 10.5.5 - version: 10.5.5(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.10) + specifier: 10.5.10 + version: 10.5.10(@vitest/browser-playwright@4.1.11)(@vitest/browser@4.1.11)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.11) '@storybook/react-vite': - specifier: 10.5.5 - version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + specifier: 10.5.10 + version: 10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) '@tokens-studio/sd-transforms': specifier: 2.0.3 - version: 2.0.3(style-dictionary@5.5.0(tslib@2.8.1)) + version: 2.0.3(style-dictionary@5.5.2(tslib@2.8.1)) '@types/node': - specifier: ^26.1.2 - version: 26.1.2 + specifier: ^26.4.0 + version: 26.4.0 '@vitest/browser': - specifier: 4.1.10 - version: 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) + specifier: 4.1.11 + version: 4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11) '@vitest/browser-playwright': - specifier: 4.1.10 - version: 4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) + specifier: 4.1.11 + version: 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11) '@vitest/coverage-v8': - specifier: 4.1.10 - version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) + specifier: 4.1.11 + version: 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) '@zip.js/zip.js': specifier: 2.8.34 version: 2.8.34(patch_hash=7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95) autoprefixer: specifier: ^10.5.4 - version: 10.5.4(postcss@8.5.25) + version: 10.5.4(postcss@8.5.26) compression: specifier: ^1.8.1 version: 1.8.1(supports-color@5.5.0) concurrently: - specifier: ^10.0.4 - version: 10.0.4 + specifier: ^10.0.5 + version: 10.0.5 date-fns: specifier: ^4.4.0 version: 4.4.0 esbuild: - specifier: ^0.28.1 - version: 0.28.1 + specifier: ^0.28.2 + version: 0.28.2 eventsource-parser: - specifier: ^3.1.0 - version: 3.1.0 + specifier: ^4.1.0 + version: 4.1.0 express: specifier: ^5.1.0 version: 5.2.1(supports-color@5.5.0) @@ -118,8 +219,8 @@ importers: specifier: ^9.1.1 version: 9.1.1 highlight.js: - specifier: ^11.10.0 - version: 11.11.1 + specifier: ^11.12.0 + version: 11.12.0 js-beautify: specifier: ^2.0.3 version: 2.0.3 @@ -136,8 +237,8 @@ importers: specifier: 0.0.7 version: 0.0.7 marked: - specifier: ^18.0.7 - version: 18.0.7 + specifier: ^18.0.11 + version: 18.0.11 mkdirp: specifier: ^3.0.1 version: 3.0.1 @@ -160,17 +261,17 @@ importers: specifier: 1.62.1 version: 1.62.1 postcss: - specifier: ^8.5.25 - version: 8.5.25 + specifier: ^8.5.26 + version: 8.5.26 postcss-clean: specifier: ^1.2.2 version: 1.2.2 postcss-modules: specifier: ^9.0.1 - version: 9.0.1(postcss@8.5.25) + version: 9.0.1(postcss@8.5.26) postcss-scss: specifier: ^4.0.9 - version: 4.0.9(postcss@8.5.25) + version: 4.0.9(postcss@8.5.26) prettier: specifier: 3.9.6 version: 3.9.6 @@ -190,8 +291,8 @@ importers: specifier: 19.2.8 version: 19.2.8(react@19.2.8) react-error-boundary: - specifier: ^6.1.2 - version: 6.1.2(react@19.2.8) + specifier: ^6.1.3 + version: 6.1.3(@types/react@19.2.18)(react@19.2.8) react-virtualized: specifier: ^9.22.6 version: 9.22.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -202,11 +303,11 @@ importers: specifier: 8.0.0-alpha.14 version: 8.0.0-alpha.14 sass: - specifier: ^1.102.0 - version: 1.102.0 + specifier: ^1.103.1 + version: 1.103.1 sass-embedded: - specifier: ^1.100.0 - version: 1.100.0 + specifier: ^1.103.1 + version: 1.103.1 sax: specifier: ^1.6.1 version: 1.6.1 @@ -217,17 +318,17 @@ importers: specifier: ^0.5.21 version: 0.5.21 storybook: - specifier: 10.5.5 - version: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + specifier: 10.5.10 + version: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) style-dictionary: - specifier: 5.5.0 - version: 5.5.0(tslib@2.8.1) + specifier: 5.5.2 + version: 5.5.2(tslib@2.8.1) stylelint: specifier: ^17.14.1 version: 17.14.1(supports-color@5.5.0)(typescript@6.0.3) stylelint-config-standard-scss: specifier: ^17.0.0 - version: 17.0.0(postcss@8.5.25)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) + version: 17.0.0(postcss@8.5.26)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) stylelint-plugin-logical-css: specifier: ^2.1.0 version: 2.1.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) @@ -238,8 +339,8 @@ importers: specifier: ^2.0.4 version: 2.0.4 tdigest: - specifier: ^0.1.2 - version: 0.1.2 + specifier: ^0.1.3 + version: 0.1.3 tinycolor2: specifier: ^1.6.0 version: 1.6.0 @@ -247,11 +348,11 @@ importers: specifier: ^6.0.2 version: 6.0.3 vite: - specifier: ^8.2.0 - version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + specifier: ^8.2.2 + version: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) vitest: - specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + specifier: ^4.1.11 + version: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) wait-on: specifier: ^9.1.0 version: 9.1.0(debug@4.4.3(supports-color@5.5.0))(supports-color@5.5.0) @@ -270,9 +371,6 @@ importers: draft-js: specifier: penpot/draft-js.git#c58ebd9429a6359d72a88cff87e078aaf6fe285d version: https://codeload.github.com/penpot/draft-js/tar.gz/c58ebd9429a6359d72a88cff87e078aaf6fe285d(encoding@0.1.13)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - immutable: - specifier: ^5.1.9 - version: 5.1.9 react: specifier: '>=0.17.0' version: 19.2.8 @@ -282,7 +380,7 @@ importers: devDependencies: esbuild: specifier: ^0.28.1 - version: 0.28.1 + version: 0.28.2 packages/mousetrap: {} @@ -298,8 +396,8 @@ importers: specifier: '>=19.2' version: 19.2.8 react-aria-components: - specifier: ^1.19.0 - version: 1.19.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: ^1.20.0 + version: 1.20.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react-dom: specifier: '>=19.2' version: 19.2.8(react@19.2.8) @@ -311,26 +409,26 @@ importers: specifier: ^8.0.1 version: 8.0.1(@babel/core@8.0.1) '@storybook/react': - specifier: 10.5.5 - version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3) + specifier: 10.5.10 + version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3) '@storybook/react-vite': - specifier: 10.5.5 - version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + specifier: 10.5.10 + version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) '@testing-library/dom': specifier: 10.4.1 version: 10.4.1 '@testing-library/react': specifier: 16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@types/react': specifier: ^19.2.18 version: 19.2.18 '@types/react-dom': - specifier: ^19.2.4 - version: 19.2.4(@types/react@19.2.18) + specifier: ^19.2.5 + version: 19.2.5(@types/react@19.2.18) '@vitejs/plugin-react': - specifier: ^6.0.5 - version: 6.0.5(babel-plugin-react-compiler@1.0.0)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + specifier: ^6.1.0 + version: 6.1.0(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -350,11 +448,11 @@ importers: specifier: ^1.0.0 version: 1.0.0(react@19.2.8) storybook: - specifier: 10.5.5 - version: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + specifier: 10.5.10 + version: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) vite-plugin-dts: specifier: ^5.0.3 - version: 5.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.2))(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + version: 5.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.4.0))(esbuild@0.28.2)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) text-editor: devDependencies: @@ -363,22 +461,22 @@ importers: version: 1.62.1 '@types/node': specifier: ^26.1.2 - version: 26.1.2 + version: 26.4.0 '@vitest/browser': specifier: ^4.1.10 - version: 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) + version: 4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11) '@vitest/coverage-v8': specifier: ^4.1.10 - version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) + version: 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) '@vitest/ui': - specifier: ^4.1.10 - version: 4.1.10(vitest@4.1.10) + specifier: ^4.1.11 + version: 4.1.11(vitest@4.1.11) canvas: specifier: ^3.2.3 version: 3.2.3 esbuild: specifier: ^0.28.0 - version: 0.28.1 + version: 0.28.2 jsdom: specifier: ^30.0.1 version: 30.0.1(canvas@3.2.3) @@ -390,10 +488,10 @@ importers: version: 3.9.6 vite: specifier: ^8.2.0 - version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + version: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + version: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) packages: @@ -442,6 +540,10 @@ packages: resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + '@babel/generator@8.0.0': resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} engines: {node: ^22.18.0 || >=24.11.0} @@ -523,6 +625,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/parser@8.0.0': resolution: {integrity: sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==} engines: {node: ^22.18.0 || >=24.11.0} @@ -584,6 +691,10 @@ packages: resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@8.0.0': resolution: {integrity: sha512-bxTj/W2VclGE6CctlfQOpxg8MPDzXArRqkOBePw8EHfebcjF7fETWSS3BriEECo+UiU/Yblq+xUtSImFu7cTbw==} engines: {node: ^22.18.0 || >=24.11.0} @@ -592,6 +703,10 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@babel/types@8.0.0': resolution: {integrity: sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==} engines: {node: ^22.18.0 || >=24.11.0} @@ -690,8 +805,8 @@ packages: '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} - '@emnapi/core@1.11.2': - resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/core@1.11.0': + resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==} '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} @@ -699,8 +814,8 @@ packages: '@emnapi/core@2.0.0-alpha.3': resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==} - '@emnapi/runtime@1.11.2': - resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.11.0': + resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==} '@emnapi/runtime@1.9.2': resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} @@ -717,158 +832,158 @@ packages: '@emnapi/wasi-threads@2.0.1': resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -960,14 +1075,14 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@internationalized/date@3.12.2': - resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==} + '@internationalized/date@3.12.3': + resolution: {integrity: sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==} '@internationalized/number@3.6.7': resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==} - '@internationalized/string@3.2.9': - resolution: {integrity: sha512-kzP/M/mbQxODlmOt4bIQZ2SBVUWUSqMLXooXixnX7noche8WHaQcA+nwFN1K2KCF/cp+LDUhcJsCicwkvhD1pg==} + '@internationalized/string@3.2.10': + resolution: {integrity: sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==} '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0': resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==} @@ -1297,106 +1412,109 @@ packages: '@oxc-project/types@0.142.0': resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} - '@oxc-resolver/binding-android-arm-eabi@11.24.2': - resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + + '@oxc-resolver/binding-android-arm-eabi@11.21.2': + resolution: {integrity: sha512-xQoCRv+gKax9KTdwdaQNnAFOai8neay7g3jExDIORzhbrejwGSJaZNTdOJHR5ziLg2joMxOCMOFMo4zuxza2uQ==} cpu: [arm] os: [android] - '@oxc-resolver/binding-android-arm64@11.24.2': - resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + '@oxc-resolver/binding-android-arm64@11.21.2': + resolution: {integrity: sha512-HF5oiE2L05yInPYCFD/4uxSrEZW4SuIfn99Y6L1xnJnzl066JR+MJs2rIdstw8A2MPlAKH+13dpFPNycjqzvGg==} cpu: [arm64] os: [android] - '@oxc-resolver/binding-darwin-arm64@11.24.2': - resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + '@oxc-resolver/binding-darwin-arm64@11.21.2': + resolution: {integrity: sha512-UX4u49CVCAD8QZNELaW8eMGgMAGwFWYEPbvNsh+3r/gs4NX3KfpiACMVwRQT0EuH3uat9hM5Zl+Ppm9pJD8tgg==} cpu: [arm64] os: [darwin] - '@oxc-resolver/binding-darwin-x64@11.24.2': - resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + '@oxc-resolver/binding-darwin-x64@11.21.2': + resolution: {integrity: sha512-J9xPx7YBkrRmJ+xl561ztnMWEc1aOyjEIxBiGX1dVb3u7bGSnfObfcZk+Pd+uM0HZAPNsQ1xvD8j52A/uOSqNQ==} cpu: [x64] os: [darwin] - '@oxc-resolver/binding-freebsd-x64@11.24.2': - resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + '@oxc-resolver/binding-freebsd-x64@11.21.2': + resolution: {integrity: sha512-fRlt7OvSaQkWj6+EDTVxawVxOlqJB2QSnBfkeCyK4RTvsGctbw3BiH2Tb7DzMs7bikc4BRBpvWP5zF9K8b54Zg==} cpu: [x64] os: [freebsd] - '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': - resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + '@oxc-resolver/binding-linux-arm-gnueabihf@11.21.2': + resolution: {integrity: sha512-gK+vPUcPQITkGwBKpZGrcDHSlU6eDGl7AQacxS2CEKAZIBHWkOVFeJwLZ4tYnA1acJqRM5lt7yYwPCVqGHIJ7A==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': - resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + '@oxc-resolver/binding-linux-arm-musleabihf@11.21.2': + resolution: {integrity: sha512-L/Rgas7SrOKy/z7IH+HxSFRqVO4PuLDKLEGKvnhoCBBq3UJ0YzGBou3qMzaAGgkJwsIvX2pBF+7ojzfUhLiZxQ==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': - resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + '@oxc-resolver/binding-linux-arm64-gnu@11.21.2': + resolution: {integrity: sha512-CUEYvlX1Fk7E9kUMzuswru1J9HLxMwnpDeQGjQuI4ZH+iNCoa2X9T+pvyzrbsgl7WnIeTFTlNlHsRfVdf5g9/g==} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-arm64-musl@11.24.2': - resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + '@oxc-resolver/binding-linux-arm64-musl@11.21.2': + resolution: {integrity: sha512-ViN1ZibQyxwC67GpoP33oo+S9UyUnkog13vzQb9+v9bCNvrVzJuk0MRdacjtv/9xfRMVF/eqHFyl8YOeykI10Q==} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': - resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + '@oxc-resolver/binding-linux-ppc64-gnu@11.21.2': + resolution: {integrity: sha512-CU1sCqWnhGqYD5I1HedHk5pujrn7ssDkNB/AEQd/pd3D/EojVSgJUlpbafwIbyhia3PgIfkvdFpRPWSALzVumA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': - resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + '@oxc-resolver/binding-linux-riscv64-gnu@11.21.2': + resolution: {integrity: sha512-jWVyZtIHca4Gb96x7dag+y69vlei7ffjrsveLkmf2ZhqEAz6ZSBnY1GWvgZUaZlwZP62A3xD092BW97Q5VGc+g==} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': - resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + '@oxc-resolver/binding-linux-riscv64-musl@11.21.2': + resolution: {integrity: sha512-LF29obFqNgBUgDX7rmUK7M4D0JQG5LxhYzn3xXmECcHU9aQAdWG7NiY052qybtesEdwHQXKNTWYQ7mTsybNvWg==} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': - resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + '@oxc-resolver/binding-linux-s390x-gnu@11.21.2': + resolution: {integrity: sha512-+NYcm+cCHBbtdQQ3A4phQTSuVRYnNHz7wrl9XRAPEovcdoqi0mb1K5ZOl+jN54ZD+q1zz3V0vltbFJmzecJKmw==} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-x64-gnu@11.24.2': - resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + '@oxc-resolver/binding-linux-x64-gnu@11.21.2': + resolution: {integrity: sha512-UQqZDdG2r2HhAOsZEgufkIWHPQ886IUyuJQkoZByvzhW8j51R4UNzGBJFkTiTnLhQnggwJRdJgFWK4uY6ZVIMw==} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-x64-musl@11.24.2': - resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + '@oxc-resolver/binding-linux-x64-musl@11.21.2': + resolution: {integrity: sha512-3Q9PMRjalWkT6NZ4jfujuqTCFwoWErg3y3BnOgb544B8IMw4PktiwWOigMfOHNRLMghZeJ7hpfpZf4CP7rV7Og==} cpu: [x64] os: [linux] libc: [musl] - '@oxc-resolver/binding-openharmony-arm64@11.24.2': - resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + '@oxc-resolver/binding-openharmony-arm64@11.21.2': + resolution: {integrity: sha512-Eljeq3ndtyKhM+Es8LITi4Zl2htzuRZcrPMF3kMCsrILztvU6AjZ3FuEhHHKobPUB9rMpzLpJP5bDqXI7r+iog==} cpu: [arm64] os: [openharmony] - '@oxc-resolver/binding-wasm32-wasi@11.24.2': - resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + '@oxc-resolver/binding-wasm32-wasi@11.21.2': + resolution: {integrity: sha512-HGDbNsIywqc4LxU38+CTJNnB/6BF7rheWOJ259b4eE0aEnelYblC8x+1tEd63bp31fYne63RQz9Jb4yVnC7Yig==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': - resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + '@oxc-resolver/binding-win32-arm64-msvc@11.21.2': + resolution: {integrity: sha512-iWx25CBEgH49iE9q5coEGI/jb1jl5kkCY9z6U5Og67xCkQ/WFMDc2J5U78+AE91SUxM2NqSLhJC8/PLfWnImww==} cpu: [arm64] os: [win32] - '@oxc-resolver/binding-win32-x64-msvc@11.24.2': - resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + '@oxc-resolver/binding-win32-x64-msvc@11.21.2': + resolution: {integrity: sha512-VPoCAhKvCQTlG7vxqaBXcmuvbh77BfnGXj8g0pbvVXpm1F/R8rDVwqIWcEbMzrI1JvJlm8v7T9uMVQb6UctMRg==} cpu: [x64] os: [win32] @@ -1498,8 +1616,8 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@react-types/shared@3.36.0': - resolution: {integrity: sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==} + '@react-types/shared@3.36.1': + resolution: {integrity: sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -1583,36 +1701,72 @@ packages: resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} engines: {node: '>= 10'} + '@rolldown/binding-android-arm-eabi@1.2.6': + resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@rolldown/binding-android-arm64@1.2.1': resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@rolldown/binding-android-arm64@1.2.6': + resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-darwin-arm64@1.2.1': resolution: {integrity: sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.2.6': + resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.2.1': resolution: {integrity: sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.2.6': + resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.2.1': resolution: {integrity: sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.2.6': + resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': resolution: {integrity: sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.2.1': resolution: {integrity: sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1620,6 +1774,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.2.6': + resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.2.1': resolution: {integrity: sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1627,6 +1788,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.2.6': + resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-ppc64-gnu@1.2.1': resolution: {integrity: sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1634,6 +1802,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.2.1': resolution: {integrity: sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1641,6 +1816,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.2.6': + resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.2.1': resolution: {integrity: sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1648,6 +1830,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.2.6': + resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.2.1': resolution: {integrity: sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1655,12 +1844,25 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-x64-musl@1.2.6': + resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-openharmony-arm64@1.2.1': resolution: {integrity: sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.2.6': + resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.2.1': resolution: {integrity: sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -1671,12 +1873,24 @@ packages: cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.2.6': + resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.2.1': resolution: {integrity: sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.2.6': + resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} @@ -1870,27 +2084,27 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@storybook/addon-docs@10.5.5': - resolution: {integrity: sha512-0YpKlimS4XE0kQ8Maa5coeefQxdyDrBHg1wOP3WTPuBe4FolFSCDveR0ge2+vuUBk+fZfn2+l+3Q2jmAWaRGDg==} + '@storybook/addon-docs@10.5.10': + resolution: {integrity: sha512-06JoK3/a7FWI/6GzuidJP9iHp1/Vejboe6lzS1jW+d8ItpecriBt+oXh1VNmUM7i7PjI6pZnet+j51QnLyeOoQ==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.5 + storybook: ^10.5.10 peerDependenciesMeta: '@types/react': optional: true - '@storybook/addon-themes@10.5.5': - resolution: {integrity: sha512-ENZCJkvTdGYBRuaE3tEE6jRilMRdGgfYUhnFNEUXAg4II2iVYg9mnrq6tuQfwSVjGuEOTAUen/3YV+l7U4oOOA==} + '@storybook/addon-themes@10.5.10': + resolution: {integrity: sha512-XNTjmIBwJ0TUcIV11STGY6hcynCy6imgYCDZeDaDyNEQsCoYAlY25fYvWNfrz5jOu1MGACiaA/E0ypk03whDdQ==} peerDependencies: - storybook: ^10.5.5 + storybook: ^10.5.10 - '@storybook/addon-vitest@10.5.5': - resolution: {integrity: sha512-Ymq9ErkSkYiIDuqpJ2+hE5GCQ5J6TCLOWhutqArvwaeAO+HAibM82XNExpJ1/kvPqk9y961GDPkv2W15I88JIw==} + '@storybook/addon-vitest@10.5.10': + resolution: {integrity: sha512-JNQ9DSkLfxC8qqytBCej91zBExIZ7z97B410U2zgfQPki4HkI9Ffz97a15f5yhVZ79ppIrZ/ssI+WcyWn0ykXQ==} peerDependencies: '@vitest/browser': ^3.0.0 || ^4.0.0 '@vitest/browser-playwright': ^4.0.0 '@vitest/runner': ^3.0.0 || ^4.0.0 - storybook: ^10.5.5 + storybook: ^10.5.10 vitest: ^3.0.0 || ^4.0.0 peerDependenciesMeta: '@vitest/browser': @@ -1902,18 +2116,18 @@ packages: vitest: optional: true - '@storybook/builder-vite@10.5.5': - resolution: {integrity: sha512-dQoJ7gUl8y0z5rV9cE0mz6qTBNmN9R4GOLIZk98rJ8CwduNJOb9eGZXusDzzvnYcp8TnNkqDtyx4tXQSUDInPQ==} + '@storybook/builder-vite@10.5.10': + resolution: {integrity: sha512-O4GgIP0tKLRueom3EmU3OaBUHKjNYj+jkOvmTIkn3PYTiWVkCuHqSKEs4ADvRyaQuLH+peHhFe4JtkNC9KbtrQ==} peerDependencies: - storybook: ^10.5.5 + storybook: ^10.5.10 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@storybook/csf-plugin@10.5.5': - resolution: {integrity: sha512-/euibhRFqklYCZqUseokojmfYcQpXshVY2QmA1qCuxMz9SzVFD3iSTw+aFLTxpsJGGdcZJk8fnm/rEthLzZ9jA==} + '@storybook/csf-plugin@10.5.10': + resolution: {integrity: sha512-TaCLBrqVEr767+w58QDotDUiCTuE5cyRJuRcDlsKQyUIyGBv+lYD3lu8wBiVCYxgIjB/gu9HmqiC+0yx1rHzaw==} peerDependencies: esbuild: '*' rollup: '*' - storybook: ^10.5.5 + storybook: ^10.5.10 vite: '*' webpack: '*' peerDependenciesMeta: @@ -1934,40 +2148,40 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@storybook/react-dom-shim@10.5.5': - resolution: {integrity: sha512-PIk7N3LLrZIxfNxmkvmQN1d5UQ70XEedT8n0GhBiXnM6XL09xPGB8n8TZXeJBRYluKhDQcAyQeT0/OZmcDVQJg==} + '@storybook/react-dom-shim@10.5.10': + resolution: {integrity: sha512-rbu62ILo/VE3iXKmu+kWXFpD1H1Lwi0f19q/x7JnDsD2dxKS9w5znLEqPIq2qxpzi/wjjIb2iUP1cRG1d/9W5A==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.5 + storybook: ^10.5.10 peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true - '@storybook/react-vite@10.5.5': - resolution: {integrity: sha512-Uy7VV72kVSkw6aDTAPQupXUeZX5LF6e4zqNvTZ+36qxsXAkaFgw7HPEm7L1tsaRfiV+s9anU7UvX47tfJpYGuQ==} + '@storybook/react-vite@10.5.10': + resolution: {integrity: sha512-xOztxefUnqKeuyvcnjspqmlDnER4cExL+liltrpdXLPJVqfFNr9lgM49FyEPajzsUVG9W/vHJWjbaQGGu1UsYQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.5 + storybook: ^10.5.10 typescript: '>= 4.9.x' vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: typescript: optional: true - '@storybook/react@10.5.5': - resolution: {integrity: sha512-T2Xj0ey7a9RHU6coYLC0L5lhjcdyhLCs9wNv15FvHvgmrRobkynEV72kq5vGW8tFkahNWI1X9+GZPQ6r8Nm38w==} + '@storybook/react@10.5.10': + resolution: {integrity: sha512-4MBV5e1SXIMfPynLHzr+Mp0dwGv/FW1bklWAsS4ynBOAbC98W9p/I9vqBnUctsvE3BJkhzHQQyPwMHL5tTcHVA==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.5 + storybook: ^10.5.10 typescript: '>= 4.9.x' peerDependenciesMeta: '@types/react': @@ -2003,8 +2217,8 @@ packages: '@types/react-dom': optional: true - '@testing-library/user-event@14.6.1': - resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + '@testing-library/user-event@14.6.3': + resolution: {integrity: sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g==} engines: {node: '>=12', npm: '>=6'} peerDependencies: '@testing-library/dom': '>=7.21.4' @@ -2071,14 +2285,19 @@ packages: '@types/mdx@2.0.14': resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==} - '@types/node@26.1.2': - resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/node@26.4.0': + resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} '@types/react-dom@19.2.4': resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} peerDependencies: '@types/react': ^19.2.0 + '@types/react-dom@19.2.5': + resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==} + peerDependencies: + '@types/react': ^19.2.0 + '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} @@ -2088,35 +2307,38 @@ packages: '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} - '@vitejs/plugin-react@6.0.5': - resolution: {integrity: sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==} + '@vitejs/plugin-react@6.1.0': + resolution: {integrity: sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 vite: ^8.0.0 peerDependenciesMeta: '@rolldown/plugin-babel': optional: true babel-plugin-react-compiler: optional: true + oxc-transform-react: + optional: true - '@vitest/browser-playwright@4.1.10': - resolution: {integrity: sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==} + '@vitest/browser-playwright@4.1.11': + resolution: {integrity: sha512-riLBxPqwnJ0lWs2DN2WeUfYeKLoAjbP2Xx8cLQdSddzMi20sksIa6K2mPz79DyMZKKVKH2ksOC2yJvtNcZg8cg==} peerDependencies: playwright: 1.62.1 - vitest: 4.1.10 + vitest: 4.1.11 - '@vitest/browser@4.1.10': - resolution: {integrity: sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==} + '@vitest/browser@4.1.11': + resolution: {integrity: sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==} peerDependencies: - vitest: 4.1.10 + vitest: 4.1.11 - '@vitest/coverage-v8@4.1.10': - resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} peerDependencies: - '@vitest/browser': 4.1.10 - vitest: 4.1.10 + '@vitest/browser': 4.1.11 + vitest: 4.1.11 peerDependenciesMeta: '@vitest/browser': optional: true @@ -2124,11 +2346,11 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/expect@4.1.10': - resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - '@vitest/mocker@4.1.10': - resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2144,22 +2366,28 @@ packages: '@vitest/pretty-format@4.1.10': resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + '@vitest/runner@4.1.10': resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} - '@vitest/snapshot@4.1.10': - resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@vitest/spy@4.1.10': - resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@vitest/ui@4.1.10': - resolution: {integrity: sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==} + '@vitest/ui@4.1.11': + resolution: {integrity: sha512-r/rwyKoev21mWdRGSEkZOqkQ2BYy68mwjihg9M90nNRbf4NGrgzZ4cj6JNCEwlOGJkbKeMgsjlykvwKUbRr7gw==} peerDependencies: - vitest: 4.1.10 + vitest: 4.1.11 '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} @@ -2167,6 +2395,9 @@ packages: '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -2175,6 +2406,11 @@ packages: '@volar/typescript@2.4.28': resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} @@ -2356,7 +2592,7 @@ packages: engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} @@ -2404,8 +2640,8 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} boolbase@1.0.0: @@ -2415,12 +2651,12 @@ packages: resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} engines: {node: '>=20.19.0'} - brace-expansion@1.1.15: - resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -2588,6 +2824,9 @@ packages: colorjs.io@0.5.2: resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} + colorjs.io@0.7.1: + resolution: {integrity: sha512-LY7OHnJZxHwT5UlzNa9bbhHHDbzB6yE5+3MIPwJEQKRvSCt/T4G7epsj+9j2BExUIIfXFIJGsIXUKdfrK9Q5tA==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -2622,8 +2861,8 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concurrently@10.0.4: - resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==} + concurrently@10.0.5: + resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==} engines: {node: '>=22'} hasBin: true @@ -2644,6 +2883,10 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -2992,8 +3235,8 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -3079,6 +3322,7 @@ packages: eslint@9.39.2: resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -3125,9 +3369,9 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - eventsource-parser@3.1.0: - resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} - engines: {node: '>=18.0.0'} + eventsource-parser@4.1.0: + resolution: {integrity: sha512-+DHvQ1wLO//MK+1OZgcuXCbZFKgu3YjKPJt7n98rxX8vezL0ni+7s3ZQiM8bJkUEOk7MsuCycrPLj0FzUNf7Og==} + engines: {node: '>=22.12'} expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} @@ -3165,8 +3409,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.4: - resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} @@ -3424,8 +3668,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - highlight.js@11.11.1: - resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + highlight.js@11.12.0: + resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==} engines: {node: '>=12.0.0'} hookified@1.15.1: @@ -3472,7 +3716,7 @@ packages: resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -3974,8 +4218,8 @@ packages: map-stream@0.0.7: resolution: {integrity: sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==} - marked@18.0.7: - resolution: {integrity: sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA==} + marked@18.0.11: + resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==} engines: {node: '>= 20'} hasBin: true @@ -4087,8 +4331,8 @@ packages: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -4232,8 +4476,8 @@ packages: resolution: {integrity: sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==} engines: {node: ^20.19.0 || >=22.12.0} - oxc-resolver@11.24.2: - resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + oxc-resolver@11.21.2: + resolution: {integrity: sha512-w5tLwYN3Zo24w5EeWJjJWZOwhYqTtC8PS2B1tIt7BZUuqTIcU07sQValbDw+rq7+AuAGzOHklgK+ifsy4lpXfw==} p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} @@ -4318,6 +4562,9 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + picocolors@0.2.1: + resolution: {integrity: sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4377,31 +4624,31 @@ packages: resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 postcss-modules-local-by-default@4.2.0: resolution: {integrity: sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 postcss-modules-scope@3.2.1: resolution: {integrity: sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 postcss-modules-values@4.0.0: resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 postcss-modules@9.0.1: resolution: {integrity: sha512-BrSXxWSls23TzqMuplpeMRL5VHnDOLh2H9EiHNTMIdLBFumJcurDIi47TBuvkn9GsoTLAoPjv2wLzAt1wdQ2aQ==} engines: {node: '>=20.6'} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 postcss-resolve-nested-selector@0.1.6: resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} @@ -4416,7 +4663,7 @@ packages: resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} engines: {node: '>=12.0'} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 postcss-selector-parser@7.1.4: resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} @@ -4428,8 +4675,12 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.25: - resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + postcss@7.0.39: + resolution: {integrity: sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==} + engines: {node: '>=6.0.0'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} prebuild-install@7.1.3: @@ -4530,14 +4781,14 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true - react-aria-components@1.19.0: - resolution: {integrity: sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==} + react-aria-components@1.20.0: + resolution: {integrity: sha512-BMbpIgoV9aELeBrB0Y120NgoigHb5OdcJwc+4e7uSnbTbamea6lo+gqcc4LAxzMaK3Jf+7LI1oCDE6yANsmxIQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-aria@3.50.0: - resolution: {integrity: sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==} + react-aria@3.51.0: + resolution: {integrity: sha512-AyWLw0XR38cFPwBu/ErgGaVrc5dupLEKmRlMXTGvFKOtbaGRQ2+yQJkjVhpdHhoRhU4+G+tJDFeHDTS8tK3bfQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -4561,10 +4812,14 @@ packages: peerDependencies: react: ^19.2.8 - react-error-boundary@6.1.2: - resolution: {integrity: sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng==} + react-error-boundary@6.1.3: + resolution: {integrity: sha512-GnSKpCohFi2nQmJCWwP8O8wub7zexlePvpsejvQr35vS5RTouS1+utTNOmyc540yw5vyOXnSL1rBWsCQDmkyUA==} peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 react: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -4575,8 +4830,8 @@ packages: react-lifecycles-compat@3.0.4: resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} - react-stately@3.48.0: - resolution: {integrity: sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==} + react-stately@3.49.0: + resolution: {integrity: sha512-13iNq2KzBrRAzxRc+n53hgROfIistiYY/sPtIhCw1qUB7/kmo+X1xEU2uiS5zcCIrc55AUPwoHqOIIpKWSwB9A==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -4671,6 +4926,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rolldown@1.2.6: + resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup@4.61.1: resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4718,130 +4978,125 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sass-embedded-all-unknown@1.100.0: - resolution: {integrity: sha512-auFtXY/kwYILmSVjtBDwyj0axcLbYYiffOKWoaXHnI5bsYwiRbBh3EneR1rpbX2ZIZCrwX93i5pxKLTZF/662Q==} + sass-embedded-all-unknown@1.103.1: + resolution: {integrity: sha512-MwX2ap06TbImAqlbnH6EndgBBejk6Eq2QB6Ae2hukCn9wmXf9cRu27um3mz3tyB4oJOOku0xfHqFH/+ckYj6YA==} cpu: ['!arm', '!arm64', '!riscv64', '!x64'] - sass-embedded-android-arm64@1.100.0: - resolution: {integrity: sha512-W+Ru9JwTnfU0UX3jSZcbqFdtKFMcYdfFwytc57h2DgnqCOIiAqI2E06mABZBZC+r3LwXCBuS5GbXAGeVgvVDkA==} + sass-embedded-android-arm64@1.103.1: + resolution: {integrity: sha512-jBXWMksyz55XeLOWvQs64BqiG5DcqflmMxcvMoA2oHtQT/7XZ02hBn502qpcF/q8Qd+qqG/hU5ccM+hGXrbQZw==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [android] - sass-embedded-android-arm@1.100.0: - resolution: {integrity: sha512-70f3HgX2pFNmzpGQ86n5e6QfWn2fP4QUQGfFQK0P1XH73ZLIzLo2YqygrGKGKeeqtc5eU2Wl1/xQzhzuKnO4kw==} + sass-embedded-android-arm@1.103.1: + resolution: {integrity: sha512-p36WHpsu5HEo2+NVNbI2Nmb8VgVH0xwOQyzghvZhF7ikGCzEVYI5BP28vHUZlCcgj2TUvMEJKI2lV336a+F/sg==} engines: {node: '>=14.0.0'} cpu: [arm] os: [android] - sass-embedded-android-riscv64@1.100.0: - resolution: {integrity: sha512-icU3o0V/uCSytSpf+tX5Lf51BvyQEbLzDUJfUi9etSauYBGHpPKkdtdZH0si4v98phq11Kl8rSV1SggksxF1Hg==} + sass-embedded-android-riscv64@1.103.1: + resolution: {integrity: sha512-rzrH7RNntk0rx+zCE9xOiAIUt37D1cQKvFfcoX0xiePdGbNHBN8DWKaWN9vaCRfbgKCmxPjevzaqFNI/G/8E1A==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [android] - sass-embedded-android-x64@1.100.0: - resolution: {integrity: sha512-mevF9VQk6gEYByy8+jusaHGmd7Usb2ytX/DsEOd0JtOGCtcf1kh575xJ6OUBDIcJ15uLnbau/0iy1eP6WVBvWA==} + sass-embedded-android-x64@1.103.1: + resolution: {integrity: sha512-EQglAJXJutuIcqgZf7YOwzao2ND6ow/OgqN+cM48/Qaeb6AimYmZiUJmM9FFcmS3Q2n1k6/2OU3l3Hlin7bFpw==} engines: {node: '>=14.0.0'} cpu: [x64] os: [android] - sass-embedded-darwin-arm64@1.100.0: - resolution: {integrity: sha512-1PVlYi61POo93IT/FfrG1mc1tAHxeSTyUALF2aOFmXGWjVXr3bQzEQiBGCOvQbj/ix+5hNyXFXcEMEyKvtUJJA==} + sass-embedded-darwin-arm64@1.103.1: + resolution: {integrity: sha512-rlaBeCul8pLbDRKdBAxdDxSBwFU5fbX42ci6CxW2Vbs34jWTMQ72+nE2v2KkGGce+JnYbb7UV6xDP/4uDWWi+w==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [darwin] - sass-embedded-darwin-x64@1.100.0: - resolution: {integrity: sha512-x97o3JnGyImZNCIVs9wQHJUE5QCvmVIKaH1cwrz/5dK7OT1FpeNiW+u9TUomP9hG6Ekjd8EL8NBHpxTfIhdjmg==} + sass-embedded-darwin-x64@1.103.1: + resolution: {integrity: sha512-VSKFfhI//AOct6ppFmAaSH7tlW2LOFECMGs77jIoWf0ZjvGch/3sc9lUTTdIaYIOnTS4qpZck+8OcI2D0G92QA==} engines: {node: '>=14.0.0'} cpu: [x64] os: [darwin] - sass-embedded-linux-arm64@1.100.0: - resolution: {integrity: sha512-Dwjmj8Z6VRy7rAi53JAdEwIyUjpfl7PhpSc2/LpQPQx+aO5Dp7Spaipkax0ufJl1SoDUdchCsM4y/88YaluorQ==} + sass-embedded-linux-arm64@1.103.1: + resolution: {integrity: sha512-rC+80/Xr9svo0ka2Zr/sjD+N1zEHTw2Urm/nsEjaVp5HSH2i22jwhTKTIqie2OLl6ti+qdYnbI0sSfxOYRtgrg==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] libc: glibc - sass-embedded-linux-arm@1.100.0: - resolution: {integrity: sha512-9Ul7O1eKrc5YlhwWjkp8tZPSe3UEwSZ1uwUZOQom1HL0pRlBA6F/IlGZYFTLwnHMIP1fc77MMNaBRfc05mKMpw==} + sass-embedded-linux-arm@1.103.1: + resolution: {integrity: sha512-tJRLPUtBwXHTnBnG7I39gQvCLreuOHLLCMyKHPR1hQ7aFNwiZjADYzKMRQPNMUqti/Os9z+6I7V13FqgQHfuzg==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] libc: glibc - sass-embedded-linux-musl-arm64@1.100.0: - resolution: {integrity: sha512-XpACJB2KjSLjf2e9uuvGVdOURsoNrFqgRiihhXyUHK9W0t3LIHb7z5MA/7XGPIT9bWSOO2zyw+rH/FHtDV/Yrg==} + sass-embedded-linux-musl-arm64@1.103.1: + resolution: {integrity: sha512-jMgG/C/VMo7+ShMFdk0xjJmQmYkj1PbJu/yrQiaQHpuo5pE2G+vnkEgsuM5EM3lTQ4MbMfnf715qy1fkbNPiUQ==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] libc: musl - sass-embedded-linux-musl-arm@1.100.0: - resolution: {integrity: sha512-sl0JgbGloPyJg66XXx5UDSDScZ0oU85DpMQU4JU/sCUCFj1Z8zZ69SJWKTCNE4/jwnce7WI2zPCV5AG+RHOZJw==} + sass-embedded-linux-musl-arm@1.103.1: + resolution: {integrity: sha512-N8L/kgVzXpnH+8d6ErIzqvb0l8kHODR3OfPaf0Azw6DOLGtEjwDRUbHno+G7iogGzyPOjKsM8OdyqHJj/bpTdw==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] libc: musl - sass-embedded-linux-musl-riscv64@1.100.0: - resolution: {integrity: sha512-ShvI0Kx04mwoCARwZ0UjiT97isQvzO80tAt91zmFyHLN9kelc/IrQi940farSm2xQVPCKdeVyeG0ekBsokSpYQ==} + sass-embedded-linux-musl-riscv64@1.103.1: + resolution: {integrity: sha512-LZm8rEvI6sKU87qPOlODsYeAs7CmWy0B4ShYkjG4OMcGCcV4Ffxn25XW4TFqmruThITQvJ8WT+WGblzdoxYgbA==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] libc: musl - sass-embedded-linux-musl-x64@1.100.0: - resolution: {integrity: sha512-TDBCRWNuS4RDLQXvRc1gjZlWiWTWaWGp0Bwu/IKwJxov81lsvrCs3TihTyNXtW7V5aoN4Ky3r0QOkNb3mwmBnA==} + sass-embedded-linux-musl-x64@1.103.1: + resolution: {integrity: sha512-rmKzgk4t6RpaDhSefbbnk5yrA4pk1nHlVhJECGay8yMfzK8eglATeY9fzPgt89xCuQk6rkOiy11e+m8tLYOW5Q==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] libc: musl - sass-embedded-linux-riscv64@1.100.0: - resolution: {integrity: sha512-j4ENJGOheO+fm3j/yorLxCjBP6/XskrZx7dTLlT+lXYwN/qqCqoA/gsNLI0McS3DFM6GBwPiffzWsdWS8t6sEQ==} + sass-embedded-linux-riscv64@1.103.1: + resolution: {integrity: sha512-xydQmtxla31uMrmN6z+mAogVGdEMDmkUElVq2+udomX/TPZDYJBkApEyJl237M2VLWVv29v1N4U4i2Z4xa7SDA==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] libc: glibc - sass-embedded-linux-x64@1.100.0: - resolution: {integrity: sha512-0vUSN8j0WGtCJIOPh//EmUvYGHW0QOe5iul8qyhPk50MAcw49MA0r34AhftjDdx94ILPF6vApFs0gwHPQRlpVA==} + sass-embedded-linux-x64@1.103.1: + resolution: {integrity: sha512-Rln1oWm0MWKzzemOgQWrRFzfVBARwS9qxAidZPpCQHSohzPnWizFkmY1CUJad/YJNsA7sscueMdX63OEvxoM8w==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] libc: glibc - sass-embedded-unknown-all@1.100.0: - resolution: {integrity: sha512-c+naBgWId4MIpToXcI0DgqetjdAkwTTAxFAuOaBz7HUXLdyG1oZRrEvSsbe41nEdQOKH0vgofVFCeSQgoXOG9A==} + sass-embedded-unknown-all@1.103.1: + resolution: {integrity: sha512-p7kI4US8+n+YguKczXh+4KcodJnk+8cwv+QFdvMaz3OfPrdHV0EnhPsaVDWD9qXb1XXCEdUhpp6vQwGk3eKc3A==} os: ['!android', '!darwin', '!linux', '!win32'] - sass-embedded-win32-arm64@1.100.0: - resolution: {integrity: sha512-iE+yxj+hUXwwbqpHkXxgAWTzeRfcWxJ7SSTQEPMk48lwq3oCrWLlz5sQuWHbuTK/i0GKQfROdP+hOmPi89yjUg==} + sass-embedded-win32-arm64@1.103.1: + resolution: {integrity: sha512-/0renOj3SpZGu4TA8CZJ6qZbTxGZk1khkPfiBTmV1uFB40ESYM5VsdXNj76P07bsAjIj9X2fCjTafLQFLSd9Nw==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [win32] - sass-embedded-win32-x64@1.100.0: - resolution: {integrity: sha512-qI4F8MI7/KYoy9NdjJfhSspG42WPkADSNDvwEV7qWvCSFC83koJssRsKO2/PfY+niZz6BG65Ic/D+A11h959hw==} + sass-embedded-win32-x64@1.103.1: + resolution: {integrity: sha512-fIg60j9u5YlLUU3W8FvySMR4HHIo9R12HHFQTW7njyQ8nXqgR+t+XfCzGJloQka8g583BlAfu31Q76Ljr5o+9g==} engines: {node: '>=14.0.0'} cpu: [x64] os: [win32] - sass-embedded@1.100.0: - resolution: {integrity: sha512-Ut8wlQSk19tm7jMK6mz6cF1+e+E7tUnW2tM02zQDPnOTcVbV8qCQG8UWxZkkNlY50+hV3hqP24OOkUlMz8xBpw==} - engines: {node: '>=16.0.0'} - hasBin: true - - sass@1.100.0: - resolution: {integrity: sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==} + sass-embedded@1.103.1: + resolution: {integrity: sha512-UVIuFZPzz+4DXYbHzQ5gKaKkyP2IH3Sc/WoF63N2V7QIfnC2E9UpQAtiKlCVJTfBe43HRgHDBLnXpZkYAEBsNQ==} engines: {node: '>=20.19.0'} hasBin: true - sass@1.102.0: - resolution: {integrity: sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==} + sass@1.103.1: + resolution: {integrity: sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw==} engines: {node: '>=20.19.0'} hasBin: true @@ -5012,8 +5267,8 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - storybook@10.5.5: - resolution: {integrity: sha512-UscBIBJDloUeqntukHOhP1a5W/vouePDJbzPSxj466WK801FZtzQiMffMtkjzJiWSuj20wfaYlB2QQKh9aOYAg==} + storybook@10.5.10: + resolution: {integrity: sha512-Rz8k9ejFHsi7lbtJTaxZlhCUz4GkbJIKEoKDjXeLfr/ZhXip73E6keKxW0KH8iGeKiCqHAbJCV4YIQrxTOLiig==} hasBin: true peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -5113,8 +5368,8 @@ packages: stubborn-fs@1.2.5: resolution: {integrity: sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==} - style-dictionary@5.5.0: - resolution: {integrity: sha512-AGkOZtAc3OTz99wlzstrmj5OM5BWOW2IbmXD74sf0MXFPi271TGBdywokgd7bS3L0tKOk9M0FR+R9gnbXRSSfg==} + style-dictionary@5.5.2: + resolution: {integrity: sha512-OPXsfLzy+8YZrUmlgOGDmTlykL8xWLvhb70bHUvBKkPCY75eQabQsl+Jd4JO8nC/uuz/6UdikCHxagiBdUugtw==} engines: {node: '>=22.0.0'} hasBin: true @@ -5122,7 +5377,7 @@ packages: resolution: {integrity: sha512-x5DVehzJudcwF0od3sGpgkln2PLLranFE7twwbp7dqDINCyZvwzFkMc6TLhNOvazRiVBJYATQLouJY0xPGB8WA==} engines: {node: '>=20'} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 stylelint: ^17.0.0 peerDependenciesMeta: postcss: @@ -5138,7 +5393,7 @@ packages: resolution: {integrity: sha512-uLJS6xgOCBw5EMsDW7Ukji8l28qRoMnkRch15s0qwZpskXvWt9oPzMmcYM307m9GN4MxuWLsQh4I6hU9yI53cQ==} engines: {node: '>=20'} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 stylelint: ^17.0.0 peerDependenciesMeta: postcss: @@ -5198,8 +5453,8 @@ packages: svg-tags@1.0.0: resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} - svgo@2.8.2: - resolution: {integrity: sha512-TyzE4NVGLUFy+H/Uy4N6c3G0HEeprsVfge6Lmq+0FdQQ/zqoVYB62IsBZORsiL+o96s6ff/V6/3UQo/C0cgCAA==} + svgo@2.8.3: + resolution: {integrity: sha512-5EZD0pafXX6PphdwOGCiVLDSaV1xyuQao2blHajHLsPxr07q4mmEjdtXEWgG07ae2mIz8Ex2CDXNCTiXhy3Khw==} engines: {node: '>=10.13.0'} hasBin: true @@ -5225,8 +5480,8 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} - tdigest@0.1.2: - resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + tdigest@0.1.3: + resolution: {integrity: sha512-zbRt+lT+/H4fRItHshczHErVCQnitJk8MfMT24MqFJf3YL7SJJPqGIGeuOdvxXxM/AHFzKBl7WoyaYwqO9s3Kw==} text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} @@ -5342,6 +5597,10 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -5490,13 +5749,13 @@ packages: vite: optional: true - vite@8.2.0: - resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.4.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -5533,20 +5792,20 @@ packages: yaml: optional: true - vitest@4.1.10: - resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.10 - '@vitest/browser-preview': 4.1.10 - '@vitest/browser-webdriverio': 4.1.10 - '@vitest/coverage-istanbul': 4.1.10 - '@vitest/coverage-v8': 4.1.10 - '@vitest/ui': 4.1.10 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -5674,8 +5933,8 @@ packages: resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} engines: {node: ^20.17.0 || >=22.9.0} - ws@8.21.1: - resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + ws@8.21.2: + resolution: {integrity: sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -5858,6 +6117,14 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/generator@8.0.0': dependencies: '@babel/parser': 8.0.0 @@ -5958,6 +6225,10 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + '@babel/parser@8.0.0': dependencies: '@babel/types': 8.0.0 @@ -6044,6 +6315,30 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.8(supports-color@10.2.2)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/traverse@7.29.8(supports-color@5.5.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + '@babel/traverse@8.0.0': dependencies: '@babel/code-frame': 8.0.0 @@ -6059,6 +6354,11 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@8.0.0': dependencies: '@babel/helper-string-parser': 8.0.0 @@ -6161,7 +6461,7 @@ snapshots: enabled: 2.0.0 kuler: 2.0.0 - '@emnapi/core@1.11.2': + '@emnapi/core@1.11.0': dependencies: '@emnapi/wasi-threads': 1.2.2 tslib: 2.8.1 @@ -6179,7 +6479,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.2': + '@emnapi/runtime@1.11.0': dependencies: tslib: 2.8.1 optional: true @@ -6209,82 +6509,82 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.28.1': + '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.28.1': + '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.28.1': + '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.28.1': + '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.28.1': + '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.28.1': + '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.28.1': + '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.28.1': + '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.28.1': + '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.28.1': + '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.28.1': + '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.28.1': + '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.28.1': + '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.28.1': + '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.28.1': + '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.28.1': + '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.28.1': + '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.28.1': + '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.28.1': + '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.28.1': + '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.28.1': + '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.28.1': + '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.28.1': + '@esbuild/win32-x64@0.28.2': optional: true '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(supports-color@10.2.2))': @@ -6367,7 +6667,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@internationalized/date@3.12.2': + '@internationalized/date@3.12.3': dependencies: '@swc/helpers': 0.5.23 @@ -6375,15 +6675,15 @@ snapshots: dependencies: '@swc/helpers': 0.5.23 - '@internationalized/string@3.2.9': + '@internationalized/string@3.2.10': dependencies: '@swc/helpers': 0.5.23 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@6.0.3) - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) optionalDependencies: typescript: 6.0.3 @@ -6548,24 +6848,24 @@ snapshots: '@types/react': 19.2.18 react: 19.2.8 - '@microsoft/api-extractor-model@7.32.2(@types/node@26.1.2)': + '@microsoft/api-extractor-model@7.32.2(@types/node@26.4.0)': dependencies: '@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc-config': 0.18.1 - '@rushstack/node-core-library': 5.19.1(@types/node@26.1.2) + '@rushstack/node-core-library': 5.19.1(@types/node@26.4.0) transitivePeerDependencies: - '@types/node' optional: true - '@microsoft/api-extractor@7.56.2(@types/node@26.1.2)': + '@microsoft/api-extractor@7.56.2(@types/node@26.4.0)': dependencies: - '@microsoft/api-extractor-model': 7.32.2(@types/node@26.1.2) + '@microsoft/api-extractor-model': 7.32.2(@types/node@26.4.0) '@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc-config': 0.18.1 - '@rushstack/node-core-library': 5.19.1(@types/node@26.1.2) + '@rushstack/node-core-library': 5.19.1(@types/node@26.4.0) '@rushstack/rig-package': 0.6.0 - '@rushstack/terminal': 0.21.0(@types/node@26.1.2) - '@rushstack/ts-command-line': 5.2.0(@types/node@26.1.2) + '@rushstack/terminal': 0.21.0(@types/node@26.4.0) + '@rushstack/ts-command-line': 5.2.0(@types/node@26.4.0) diff: 8.0.4 lodash: 4.18.1 minimatch: 10.2.5 @@ -6588,10 +6888,10 @@ snapshots: '@microsoft/tsdoc@0.16.0': optional: true - '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': dependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 + '@emnapi/core': 1.11.0 + '@emnapi/runtime': 1.11.0 '@tybys/wasm-util': 0.10.3 optional: true @@ -6689,67 +6989,70 @@ snapshots: '@oxc-project/types@0.127.0': {} - '@oxc-project/types@0.142.0': {} - - '@oxc-resolver/binding-android-arm-eabi@11.24.2': + '@oxc-project/types@0.142.0': optional: true - '@oxc-resolver/binding-android-arm64@11.24.2': + '@oxc-project/types@0.147.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.21.2': optional: true - '@oxc-resolver/binding-darwin-arm64@11.24.2': + '@oxc-resolver/binding-android-arm64@11.21.2': optional: true - '@oxc-resolver/binding-darwin-x64@11.24.2': + '@oxc-resolver/binding-darwin-arm64@11.21.2': optional: true - '@oxc-resolver/binding-freebsd-x64@11.24.2': + '@oxc-resolver/binding-darwin-x64@11.21.2': optional: true - '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + '@oxc-resolver/binding-freebsd-x64@11.21.2': optional: true - '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + '@oxc-resolver/binding-linux-arm-gnueabihf@11.21.2': optional: true - '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + '@oxc-resolver/binding-linux-arm-musleabihf@11.21.2': optional: true - '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + '@oxc-resolver/binding-linux-arm64-gnu@11.21.2': optional: true - '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + '@oxc-resolver/binding-linux-arm64-musl@11.21.2': optional: true - '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + '@oxc-resolver/binding-linux-ppc64-gnu@11.21.2': optional: true - '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + '@oxc-resolver/binding-linux-riscv64-gnu@11.21.2': optional: true - '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + '@oxc-resolver/binding-linux-riscv64-musl@11.21.2': optional: true - '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + '@oxc-resolver/binding-linux-s390x-gnu@11.21.2': optional: true - '@oxc-resolver/binding-linux-x64-musl@11.24.2': + '@oxc-resolver/binding-linux-x64-gnu@11.21.2': optional: true - '@oxc-resolver/binding-openharmony-arm64@11.24.2': + '@oxc-resolver/binding-linux-x64-musl@11.21.2': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.24.2': + '@oxc-resolver/binding-openharmony-arm64@11.21.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.21.2': dependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 - '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@emnapi/core': 1.11.0 + '@emnapi/runtime': 1.11.0 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0) optional: true - '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + '@oxc-resolver/binding-win32-arm64-msvc@11.21.2': optional: true - '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + '@oxc-resolver/binding-win32-x64-msvc@11.21.2': optional: true '@parcel/watcher-android-arm64@2.6.0': @@ -6825,7 +7128,7 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@react-types/shared@3.36.0(react@19.2.8)': + '@react-types/shared@3.36.1(react@19.2.8)': dependencies: react: 19.2.8 @@ -6880,42 +7183,81 @@ snapshots: '@resvg/resvg-js-win32-ia32-msvc': 2.6.2 '@resvg/resvg-js-win32-x64-msvc': 2.6.2 + '@rolldown/binding-android-arm-eabi@1.2.6': + optional: true + '@rolldown/binding-android-arm64@1.2.1': optional: true + '@rolldown/binding-android-arm64@1.2.6': + optional: true + '@rolldown/binding-darwin-arm64@1.2.1': optional: true + '@rolldown/binding-darwin-arm64@1.2.6': + optional: true + '@rolldown/binding-darwin-x64@1.2.1': optional: true + '@rolldown/binding-darwin-x64@1.2.6': + optional: true + '@rolldown/binding-freebsd-x64@1.2.1': optional: true + '@rolldown/binding-freebsd-x64@1.2.6': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.2.1': optional: true + '@rolldown/binding-linux-arm64-gnu@1.2.6': + optional: true + '@rolldown/binding-linux-arm64-musl@1.2.1': optional: true + '@rolldown/binding-linux-arm64-musl@1.2.6': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.2.1': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.2.1': optional: true + '@rolldown/binding-linux-s390x-gnu@1.2.6': + optional: true + '@rolldown/binding-linux-x64-gnu@1.2.1': optional: true + '@rolldown/binding-linux-x64-gnu@1.2.6': + optional: true + '@rolldown/binding-linux-x64-musl@1.2.1': optional: true + '@rolldown/binding-linux-x64-musl@1.2.6': + optional: true + '@rolldown/binding-openharmony-arm64@1.2.1': optional: true + '@rolldown/binding-openharmony-arm64@1.2.6': + optional: true + '@rolldown/binding-wasm32-wasi@1.2.1': dependencies: '@emnapi/core': 2.0.0-alpha.3 @@ -6926,9 +7268,15 @@ snapshots: '@rolldown/binding-win32-arm64-msvc@1.2.1': optional: true + '@rolldown/binding-win32-arm64-msvc@1.2.6': + optional: true + '@rolldown/binding-win32-x64-msvc@1.2.1': optional: true + '@rolldown/binding-win32-x64-msvc@1.2.6': + optional: true + '@rolldown/pluginutils@1.0.1': {} '@rollup/pluginutils@5.4.0(rollup@4.61.1)': @@ -7016,7 +7364,7 @@ snapshots: '@rtsao/scc@1.1.0': {} - '@rushstack/node-core-library@5.19.1(@types/node@26.1.2)': + '@rushstack/node-core-library@5.19.1(@types/node@26.4.0)': dependencies: ajv: 8.20.0 ajv-draft-04: 1.0.0(ajv@8.20.0) @@ -7027,12 +7375,12 @@ snapshots: resolve: 1.22.12 semver: 7.5.4 optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 optional: true - '@rushstack/problem-matcher@0.1.1(@types/node@26.1.2)': + '@rushstack/problem-matcher@0.1.1(@types/node@26.4.0)': optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 optional: true '@rushstack/rig-package@0.6.0': @@ -7041,18 +7389,18 @@ snapshots: strip-json-comments: 3.1.1 optional: true - '@rushstack/terminal@0.21.0(@types/node@26.1.2)': + '@rushstack/terminal@0.21.0(@types/node@26.4.0)': dependencies: - '@rushstack/node-core-library': 5.19.1(@types/node@26.1.2) - '@rushstack/problem-matcher': 0.1.1(@types/node@26.1.2) + '@rushstack/node-core-library': 5.19.1(@types/node@26.4.0) + '@rushstack/problem-matcher': 0.1.1(@types/node@26.4.0) supports-color: 8.1.1 optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 optional: true - '@rushstack/ts-command-line@5.2.0(@types/node@26.1.2)': + '@rushstack/ts-command-line@5.2.0(@types/node@26.4.0)': dependencies: - '@rushstack/terminal': 0.21.0(@types/node@26.1.2) + '@rushstack/terminal': 0.21.0(@types/node@26.4.0) '@types/argparse': 1.0.38 argparse: 1.0.10 string-argv: 0.3.2 @@ -7069,15 +7417,15 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@storybook/addon-docs@10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.18)(react@19.2.8) - '@storybook/csf-plugin': 10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + '@storybook/csf-plugin': 10.5.10(esbuild@0.28.2)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) '@storybook/icons': 2.1.0(react@19.2.8) - '@storybook/react-dom-shim': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + '@storybook/react-dom-shim': 10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) ts-dedent: 2.3.0 optionalDependencies: '@types/react': 19.2.18 @@ -7088,43 +7436,43 @@ snapshots: - vite - webpack - '@storybook/addon-themes@10.5.5(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': + '@storybook/addon-themes@10.5.10(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': dependencies: - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) ts-dedent: 2.3.0 - '@storybook/addon-vitest@10.5.5(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.10)': + '@storybook/addon-vitest@10.5.10(@vitest/browser-playwright@4.1.11)(@vitest/browser@4.1.11)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.11)': dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.1.0(react@19.2.8) - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) optionalDependencies: - '@vitest/browser': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) - '@vitest/browser-playwright': 4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) + '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11) + '@vitest/browser-playwright': 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11) '@vitest/runner': 4.1.10 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) transitivePeerDependencies: - react - '@storybook/builder-vite@10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@storybook/builder-vite@10.5.10(esbuild@0.28.2)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))': dependencies: - '@storybook/csf-plugin': 10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + '@storybook/csf-plugin': 10.5.10(esbuild@0.28.2)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) ts-dedent: 2.3.0 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@storybook/csf-plugin@10.5.10(esbuild@0.28.2)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))': dependencies: - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) unplugin: 2.3.11 optionalDependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 rollup: 4.61.1 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) '@storybook/global@5.0.0': {} @@ -7132,30 +7480,39 @@ snapshots: dependencies: react: 19.2.8 - '@storybook/react-dom-shim@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': + '@storybook/react-dom-shim@10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': dependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@storybook/react-vite@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@storybook/react-dom-shim@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) + + '@storybook/react-vite@10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))': + dependencies: + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) '@rollup/pluginutils': 5.4.0(rollup@4.61.1) - '@storybook/builder-vite': 10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) - '@storybook/react': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3) + '@storybook/builder-vite': 10.5.10(esbuild@0.28.2)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) + '@storybook/react': 10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3) empathic: 2.0.1 magic-string: 0.30.21 react: 19.2.8 - react-docgen: 8.0.3(supports-color@10.2.2) + react-docgen: 8.0.3(supports-color@5.5.0) react-dom: 19.2.8(react@19.2.8) resolve: 1.22.12 - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) tsconfig-paths: 4.2.0 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -7166,21 +7523,21 @@ snapshots: - supports-color - webpack - '@storybook/react-vite@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@storybook/react-vite@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) '@rollup/pluginutils': 5.4.0(rollup@4.61.1) - '@storybook/builder-vite': 10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) - '@storybook/react': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3) + '@storybook/builder-vite': 10.5.10(esbuild@0.28.2)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) + '@storybook/react': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3) empathic: 2.0.1 magic-string: 0.30.21 react: 19.2.8 - react-docgen: 8.0.3(supports-color@5.5.0) + react-docgen: 8.0.3(supports-color@10.2.2) react-dom: 19.2.8(react@19.2.8) resolve: 1.22.12 - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) tsconfig-paths: 4.2.0 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -7191,15 +7548,15 @@ snapshots: - supports-color - webpack - '@storybook/react@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)': + '@storybook/react@10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + '@storybook/react-dom-shim': 10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) react: 19.2.8 - react-docgen: 8.0.3(supports-color@10.2.2) + react-docgen: 8.0.3(supports-color@5.5.0) react-docgen-typescript: 2.4.0(typescript@6.0.3) react-dom: 19.2.8(react@19.2.8) - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) @@ -7207,18 +7564,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@storybook/react@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)': + '@storybook/react@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + '@storybook/react-dom-shim': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) react: 19.2.8 - react-docgen: 8.0.3(supports-color@5.5.0) + react-docgen: 8.0.3(supports-color@10.2.2) react-docgen-typescript: 2.4.0(typescript@6.0.3) react-dom: 19.2.8(react@19.2.8) - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react-dom': 19.2.5(@types/react@19.2.18) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -7247,7 +7604,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@babel/runtime': 7.29.7 '@testing-library/dom': 10.4.1 @@ -7255,13 +7612,13 @@ snapshots: react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + '@testing-library/user-event@14.6.3(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 - '@tokens-studio/sd-transforms@2.0.3(style-dictionary@5.5.0(tslib@2.8.1))': + '@tokens-studio/sd-transforms@2.0.3(style-dictionary@5.5.2(tslib@2.8.1))': dependencies: '@bundled-es-modules/deepmerge': 4.3.2 '@bundled-es-modules/postcss-calc-ast-parser': 0.1.6 @@ -7269,7 +7626,7 @@ snapshots: colorjs.io: 0.5.2 expr-eval-fork: 3.0.3 is-mergeable-object: 1.1.1 - style-dictionary: 5.5.0(tslib@2.8.1) + style-dictionary: 5.5.2(tslib@2.8.1) '@tokens-studio/tokenscript-interpreter@0.26.0': dependencies: @@ -7292,24 +7649,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@types/chai@5.2.3': dependencies: @@ -7332,13 +7689,18 @@ snapshots: '@types/mdx@2.0.14': {} - '@types/node@26.1.2': + '@types/node@26.4.0': dependencies: undici-types: 8.3.0 '@types/react-dom@19.2.4(@types/react@19.2.18)': dependencies: '@types/react': 19.2.18 + optional: true + + '@types/react-dom@19.2.5(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 '@types/react@19.2.18': dependencies: @@ -7348,47 +7710,47 @@ snapshots: '@types/triple-beam@1.3.5': {} - '@vitejs/plugin-react@6.0.5(babel-plugin-react-compiler@1.0.0)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@vitejs/plugin-react@6.1.0(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) optionalDependencies: babel-plugin-react-compiler: 1.0.0 - '@vitest/browser-playwright@4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10)': + '@vitest/browser-playwright@4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11)': dependencies: - '@vitest/browser': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) - '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11) + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) playwright: 1.62.1 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10)': + '@vitest/browser@4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) - '@vitest/utils': 4.1.10 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) - ws: 8.21.1 + vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) + ws: 8.21.2 transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/coverage-v8@4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10)': + '@vitest/coverage-v8@4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 ast-v8-to-istanbul: 1.0.5 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -7397,9 +7759,9 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) optionalDependencies: - '@vitest/browser': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) + '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11) '@vitest/expect@3.2.4': dependencies: @@ -7409,22 +7771,22 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/expect@4.1.10': + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))': dependencies: - '@vitest/spy': 4.1.10 + '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -7433,16 +7795,27 @@ snapshots: '@vitest/pretty-format@4.1.10': dependencies: tinyrainbow: 3.1.1 + optional: true + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 '@vitest/runner@4.1.10': dependencies: '@vitest/utils': 4.1.10 pathe: 2.0.3 + optional: true - '@vitest/snapshot@4.1.10': + '@vitest/runner@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pathe: 2.0.3 @@ -7450,18 +7823,18 @@ snapshots: dependencies: tinyspy: 4.0.4 - '@vitest/spy@4.1.10': {} + '@vitest/spy@4.1.11': {} - '@vitest/ui@4.1.10(vitest@4.1.10)': + '@vitest/ui@4.1.11(vitest@4.1.11)': dependencies: - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 fflate: 0.8.3 flatted: 3.4.4 pathe: 2.0.3 sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) '@vitest/utils@3.2.4': dependencies: @@ -7474,6 +7847,13 @@ snapshots: '@vitest/pretty-format': 4.1.10 convert-source-map: 2.0.0 tinyrainbow: 3.1.1 + optional: true + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 '@volar/language-core@2.4.28': dependencies: @@ -7481,11 +7861,13 @@ snapshots: '@volar/source-map@2.4.28': {} - '@volar/typescript@2.4.28': + '@volar/typescript@2.4.28(typescript@6.0.3)': dependencies: '@volar/language-core': 2.4.28 path-browserify: 1.0.1 vscode-uri: 3.1.0 + optionalDependencies: + typescript: 6.0.3 '@webcontainer/env@1.1.1': {} @@ -7532,7 +7914,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 optional: true @@ -7540,7 +7922,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -7691,13 +8073,13 @@ snapshots: asynckit@0.4.0: {} - autoprefixer@10.5.4(postcss@8.5.25): + autoprefixer@10.5.4(postcss@8.5.26): dependencies: browserslist: 4.28.7 caniuse-lite: 1.0.30001806 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.7: @@ -7744,17 +8126,17 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - body-parser@2.2.2(supports-color@5.5.0): + body-parser@2.3.0(supports-color@5.5.0): dependencies: bytes: 3.1.2 - content-type: 1.0.5 + content-type: 2.0.0 debug: 4.4.3(supports-color@5.5.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 qs: 6.15.3 raw-body: 3.0.2 - type-is: 2.0.1 + type-is: 2.1.0 transitivePeerDependencies: - supports-color @@ -7762,12 +8144,12 @@ snapshots: boolbase@2.0.0: {} - brace-expansion@1.1.15: + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.7: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -7947,6 +8329,8 @@ snapshots: colorjs.io@0.5.2: {} + colorjs.io@0.7.1: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -7979,7 +8363,7 @@ snapshots: concat-map@0.0.1: {} - concurrently@10.0.4: + concurrently@10.0.5: dependencies: chalk: 5.6.2 rxjs: 7.8.2 @@ -8001,6 +8385,8 @@ snapshots: content-type@1.0.5: {} + content-type@2.0.0: {} + convert-source-map@2.0.0: {} cookie-signature@1.2.2: {} @@ -8409,34 +8795,34 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - esbuild@0.28.1: + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 escalade@3.2.0: {} @@ -8620,7 +9006,7 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.1.0: {} + eventsource-parser@4.1.0: {} expand-template@2.0.3: {} @@ -8631,7 +9017,7 @@ snapshots: express@5.2.1(supports-color@5.5.0): dependencies: accepts: 2.0.0 - body-parser: 2.2.2(supports-color@5.5.0) + body-parser: 2.3.0(supports-color@5.5.0) content-disposition: 1.0.1 content-type: 1.0.5 cookie: 0.7.2 @@ -8681,7 +9067,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.4: {} + fast-uri@3.1.5: {} fastest-levenshtein@1.0.16: {} @@ -8946,7 +9332,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - highlight.js@11.11.1: {} + highlight.js@11.12.0: {} hookified@1.15.1: {} @@ -8989,9 +9375,9 @@ snapshots: dependencies: safer-buffer: 2.1.2 - icss-utils@5.1.0(postcss@8.5.25): + icss-utils@5.1.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 ieee754@1.2.1: {} @@ -9454,8 +9840,8 @@ snapshots: magicast@0.5.4: dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 source-map-js: 1.2.1 make-dir@4.0.0: @@ -9464,7 +9850,7 @@ snapshots: map-stream@0.0.7: {} - marked@18.0.7: {} + marked@18.0.11: {} math-intrinsics@1.1.0: {} @@ -9526,11 +9912,11 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.9 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.15 + brace-expansion: 1.1.18 minimist@1.2.8: {} @@ -9555,7 +9941,7 @@ snapshots: mustache@4.2.0: {} - nanoid@3.3.16: {} + nanoid@3.3.18: {} napi-build-utils@2.0.0: {} @@ -9739,27 +10125,27 @@ snapshots: '@oxc-parser/binding-win32-ia32-msvc': 0.127.0 '@oxc-parser/binding-win32-x64-msvc': 0.127.0 - oxc-resolver@11.24.2: + oxc-resolver@11.21.2: optionalDependencies: - '@oxc-resolver/binding-android-arm-eabi': 11.24.2 - '@oxc-resolver/binding-android-arm64': 11.24.2 - '@oxc-resolver/binding-darwin-arm64': 11.24.2 - '@oxc-resolver/binding-darwin-x64': 11.24.2 - '@oxc-resolver/binding-freebsd-x64': 11.24.2 - '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 - '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 - '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 - '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 - '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 - '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 - '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 - '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 - '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 - '@oxc-resolver/binding-linux-x64-musl': 11.24.2 - '@oxc-resolver/binding-openharmony-arm64': 11.24.2 - '@oxc-resolver/binding-wasm32-wasi': 11.24.2 - '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 - '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + '@oxc-resolver/binding-android-arm-eabi': 11.21.2 + '@oxc-resolver/binding-android-arm64': 11.21.2 + '@oxc-resolver/binding-darwin-arm64': 11.21.2 + '@oxc-resolver/binding-darwin-x64': 11.21.2 + '@oxc-resolver/binding-freebsd-x64': 11.21.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.21.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.21.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.21.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.21.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.21.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.21.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.21.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.21.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.21.2 + '@oxc-resolver/binding-linux-x64-musl': 11.21.2 + '@oxc-resolver/binding-openharmony-arm64': 11.21.2 + '@oxc-resolver/binding-wasm32-wasi': 11.21.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.21.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.21.2 p-limit@3.1.0: dependencies: @@ -9833,6 +10219,8 @@ snapshots: pend@1.2.0: {} + picocolors@0.2.1: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -9874,52 +10262,52 @@ snapshots: postcss-clean@1.2.2: dependencies: clean-css: 4.2.4 - postcss: 8.5.25 + postcss: 7.0.39 postcss-media-query-parser@0.2.3: {} - postcss-modules-extract-imports@3.1.0(postcss@8.5.25): + postcss-modules-extract-imports@3.1.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-modules-local-by-default@4.2.0(postcss@8.5.25): + postcss-modules-local-by-default@4.2.0(postcss@8.5.26): dependencies: - icss-utils: 5.1.0(postcss@8.5.25) - postcss: 8.5.25 + icss-utils: 5.1.0(postcss@8.5.26) + postcss: 8.5.26 postcss-selector-parser: 7.1.4 postcss-value-parser: 4.2.0 - postcss-modules-scope@3.2.1(postcss@8.5.25): + postcss-modules-scope@3.2.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 7.1.4 - postcss-modules-values@4.0.0(postcss@8.5.25): + postcss-modules-values@4.0.0(postcss@8.5.26): dependencies: - icss-utils: 5.1.0(postcss@8.5.25) - postcss: 8.5.25 + icss-utils: 5.1.0(postcss@8.5.26) + postcss: 8.5.26 - postcss-modules@9.0.1(postcss@8.5.25): + postcss-modules@9.0.1(postcss@8.5.26): dependencies: generic-names: 4.0.0 - icss-utils: 5.1.0(postcss@8.5.25) + icss-utils: 5.1.0(postcss@8.5.26) lodash.camelcase: 4.3.0 - postcss: 8.5.25 - postcss-modules-extract-imports: 3.1.0(postcss@8.5.25) - postcss-modules-local-by-default: 4.2.0(postcss@8.5.25) - postcss-modules-scope: 3.2.1(postcss@8.5.25) - postcss-modules-values: 4.0.0(postcss@8.5.25) + postcss: 8.5.26 + postcss-modules-extract-imports: 3.1.0(postcss@8.5.26) + postcss-modules-local-by-default: 4.2.0(postcss@8.5.26) + postcss-modules-scope: 3.2.1(postcss@8.5.26) + postcss-modules-values: 4.0.0(postcss@8.5.26) string-hash: 1.1.3 postcss-resolve-nested-selector@0.1.6: {} - postcss-safe-parser@7.0.1(postcss@8.5.25): + postcss-safe-parser@7.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-scss@4.0.9(postcss@8.5.25): + postcss-scss@4.0.9(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser@7.1.4: dependencies: @@ -9930,9 +10318,14 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.5.25: + postcss@7.0.39: dependencies: - nanoid: 3.3.16 + picocolors: 0.2.1 + source-map: 0.6.1 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -10036,29 +10429,30 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-aria-components@1.19.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + react-aria-components@1.20.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@internationalized/date': 3.12.2 - '@react-types/shared': 3.36.0(react@19.2.8) + '@internationalized/date': 3.12.3 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.36.1(react@19.2.8) '@swc/helpers': 0.5.23 client-only: 0.0.1 react: 19.2.8 - react-aria: 3.50.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-aria: 3.51.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react-dom: 19.2.8(react@19.2.8) - react-stately: 3.48.0(react@19.2.8) + react-stately: 3.49.0(react@19.2.8) - react-aria@3.50.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + react-aria@3.51.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@internationalized/date': 3.12.2 + '@internationalized/date': 3.12.3 '@internationalized/number': 3.6.7 - '@internationalized/string': 3.2.9 - '@react-types/shared': 3.36.0(react@19.2.8) + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.36.1(react@19.2.8) '@swc/helpers': 0.5.23 aria-hidden: 1.2.6 clsx: 2.1.1 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - react-stately: 3.48.0(react@19.2.8) + react-stately: 3.49.0(react@19.2.8) use-sync-external-store: 1.6.0(react@19.2.8) react-compiler-runtime@1.0.0(react@19.2.8): @@ -10072,8 +10466,8 @@ snapshots: react-docgen@8.0.3(supports-color@10.2.2): dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/traverse': 7.29.7(supports-color@10.2.2) - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 '@types/doctrine': 0.0.9 @@ -10087,8 +10481,8 @@ snapshots: react-docgen@8.0.3(supports-color@5.5.0): dependencies: '@babel/core': 7.29.7(supports-color@5.5.0) - '@babel/traverse': 7.29.7(supports-color@5.5.0) - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@5.5.0) + '@babel/types': 7.29.8 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 '@types/doctrine': 0.0.9 @@ -10104,9 +10498,11 @@ snapshots: react: 19.2.8 scheduler: 0.27.0 - react-error-boundary@6.1.2(react@19.2.8): + react-error-boundary@6.1.3(@types/react@19.2.18)(react@19.2.8): dependencies: react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 react-is@16.13.1: {} @@ -10114,12 +10510,12 @@ snapshots: react-lifecycles-compat@3.0.4: {} - react-stately@3.48.0(react@19.2.8): + react-stately@3.49.0(react@19.2.8): dependencies: - '@internationalized/date': 3.12.2 + '@internationalized/date': 3.12.3 '@internationalized/number': 3.6.7 - '@internationalized/string': 3.2.9 - '@react-types/shared': 3.36.0(react@19.2.8) + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.36.1(react@19.2.8) '@swc/helpers': 0.5.23 react: 19.2.8 use-sync-external-store: 1.6.0(react@19.2.8) @@ -10250,6 +10646,28 @@ snapshots: '@rolldown/binding-wasm32-wasi': 1.2.1 '@rolldown/binding-win32-arm64-msvc': 1.2.1 '@rolldown/binding-win32-x64-msvc': 1.2.1 + optional: true + + rolldown@1.2.6: + dependencies: + '@oxc-project/types': 0.147.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.6 + '@rolldown/binding-android-arm64': 1.2.6 + '@rolldown/binding-darwin-arm64': 1.2.6 + '@rolldown/binding-darwin-x64': 1.2.6 + '@rolldown/binding-freebsd-x64': 1.2.6 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.6 + '@rolldown/binding-linux-arm64-gnu': 1.2.6 + '@rolldown/binding-linux-arm64-musl': 1.2.6 + '@rolldown/binding-linux-ppc64-gnu': 1.2.6 + '@rolldown/binding-linux-s390x-gnu': 1.2.6 + '@rolldown/binding-linux-x64-gnu': 1.2.6 + '@rolldown/binding-linux-x64-musl': 1.2.6 + '@rolldown/binding-openharmony-arm64': 1.2.6 + '@rolldown/binding-win32-arm64-msvc': 1.2.6 + '@rolldown/binding-win32-x64-msvc': 1.2.6 rollup@4.61.1: dependencies: @@ -10332,103 +10750,94 @@ snapshots: safer-buffer@2.1.2: {} - sass-embedded-all-unknown@1.100.0: + sass-embedded-all-unknown@1.103.1: dependencies: - sass: 1.100.0 + sass: 1.103.1 optional: true - sass-embedded-android-arm64@1.100.0: + sass-embedded-android-arm64@1.103.1: optional: true - sass-embedded-android-arm@1.100.0: + sass-embedded-android-arm@1.103.1: optional: true - sass-embedded-android-riscv64@1.100.0: + sass-embedded-android-riscv64@1.103.1: optional: true - sass-embedded-android-x64@1.100.0: + sass-embedded-android-x64@1.103.1: optional: true - sass-embedded-darwin-arm64@1.100.0: + sass-embedded-darwin-arm64@1.103.1: optional: true - sass-embedded-darwin-x64@1.100.0: + sass-embedded-darwin-x64@1.103.1: optional: true - sass-embedded-linux-arm64@1.100.0: + sass-embedded-linux-arm64@1.103.1: optional: true - sass-embedded-linux-arm@1.100.0: + sass-embedded-linux-arm@1.103.1: optional: true - sass-embedded-linux-musl-arm64@1.100.0: + sass-embedded-linux-musl-arm64@1.103.1: optional: true - sass-embedded-linux-musl-arm@1.100.0: + sass-embedded-linux-musl-arm@1.103.1: optional: true - sass-embedded-linux-musl-riscv64@1.100.0: + sass-embedded-linux-musl-riscv64@1.103.1: optional: true - sass-embedded-linux-musl-x64@1.100.0: + sass-embedded-linux-musl-x64@1.103.1: optional: true - sass-embedded-linux-riscv64@1.100.0: + sass-embedded-linux-riscv64@1.103.1: optional: true - sass-embedded-linux-x64@1.100.0: + sass-embedded-linux-x64@1.103.1: optional: true - sass-embedded-unknown-all@1.100.0: + sass-embedded-unknown-all@1.103.1: dependencies: - sass: 1.100.0 + sass: 1.103.1 optional: true - sass-embedded-win32-arm64@1.100.0: + sass-embedded-win32-arm64@1.103.1: optional: true - sass-embedded-win32-x64@1.100.0: + sass-embedded-win32-x64@1.103.1: optional: true - sass-embedded@1.100.0: + sass-embedded@1.103.1: dependencies: '@bufbuild/protobuf': 2.12.1 - colorjs.io: 0.5.2 + colorjs.io: 0.7.1 immutable: 5.1.9 rxjs: 7.8.2 supports-color: 8.1.1 sync-child-process: 1.0.2 varint: 6.0.0 optionalDependencies: - sass-embedded-all-unknown: 1.100.0 - sass-embedded-android-arm: 1.100.0 - sass-embedded-android-arm64: 1.100.0 - sass-embedded-android-riscv64: 1.100.0 - sass-embedded-android-x64: 1.100.0 - sass-embedded-darwin-arm64: 1.100.0 - sass-embedded-darwin-x64: 1.100.0 - sass-embedded-linux-arm: 1.100.0 - sass-embedded-linux-arm64: 1.100.0 - sass-embedded-linux-musl-arm: 1.100.0 - sass-embedded-linux-musl-arm64: 1.100.0 - sass-embedded-linux-musl-riscv64: 1.100.0 - sass-embedded-linux-musl-x64: 1.100.0 - sass-embedded-linux-riscv64: 1.100.0 - sass-embedded-linux-x64: 1.100.0 - sass-embedded-unknown-all: 1.100.0 - sass-embedded-win32-arm64: 1.100.0 - sass-embedded-win32-x64: 1.100.0 + sass-embedded-all-unknown: 1.103.1 + sass-embedded-android-arm: 1.103.1 + sass-embedded-android-arm64: 1.103.1 + sass-embedded-android-riscv64: 1.103.1 + sass-embedded-android-x64: 1.103.1 + sass-embedded-darwin-arm64: 1.103.1 + sass-embedded-darwin-x64: 1.103.1 + sass-embedded-linux-arm: 1.103.1 + sass-embedded-linux-arm64: 1.103.1 + sass-embedded-linux-musl-arm: 1.103.1 + sass-embedded-linux-musl-arm64: 1.103.1 + sass-embedded-linux-musl-riscv64: 1.103.1 + sass-embedded-linux-musl-x64: 1.103.1 + sass-embedded-linux-riscv64: 1.103.1 + sass-embedded-linux-x64: 1.103.1 + sass-embedded-unknown-all: 1.103.1 + sass-embedded-win32-arm64: 1.103.1 + sass-embedded-win32-x64: 1.103.1 - sass@1.100.0: - dependencies: - chokidar: 5.0.0 - immutable: 5.1.9 - source-map-js: 1.2.1 - optionalDependencies: - '@parcel/watcher': 2.6.0 - optional: true - - sass@1.102.0: + sass@1.103.1: dependencies: chokidar: 5.0.0 immutable: 5.1.9 @@ -10619,25 +11028,25 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8): + storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8): dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.1.0(react@19.2.8) '@testing-library/dom': 10.4.1 '@testing-library/jest-dom': 6.9.1 - '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@testing-library/user-event': 14.6.3(@testing-library/dom@10.4.1) '@vitest/expect': 3.2.4 '@vitest/spy': 3.2.4 '@webcontainer/env': 1.1.1 - esbuild: 0.28.1 + esbuild: 0.28.2 jsonc-parser: 3.3.1 open: 10.2.0 oxc-parser: 0.127.0 - oxc-resolver: 11.24.2 + oxc-resolver: 11.21.2 recast: 0.23.19 semver: 7.8.5 use-sync-external-store: 1.6.0(react@19.2.8) - ws: 8.21.1 + ws: 8.21.2 optionalDependencies: '@types/react': 19.2.18 prettier: 3.9.6 @@ -10759,7 +11168,7 @@ snapshots: stubborn-fs@1.2.5: {} - style-dictionary@5.5.0(tslib@2.8.1): + style-dictionary@5.5.2(tslib@2.8.1): dependencies: '@bundled-es-modules/deepmerge': 4.3.2 '@bundled-es-modules/glob': 13.0.6 @@ -10777,26 +11186,26 @@ snapshots: transitivePeerDependencies: - tslib - stylelint-config-recommended-scss@17.0.1(postcss@8.5.25)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)): + stylelint-config-recommended-scss@17.0.1(postcss@8.5.26)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)): dependencies: - postcss-scss: 4.0.9(postcss@8.5.25) + postcss-scss: 4.0.9(postcss@8.5.26) stylelint: 17.14.1(supports-color@5.5.0)(typescript@6.0.3) stylelint-config-recommended: 18.0.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) stylelint-scss: 7.2.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) optionalDependencies: - postcss: 8.5.25 + postcss: 8.5.26 stylelint-config-recommended@18.0.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)): dependencies: stylelint: 17.14.1(supports-color@5.5.0)(typescript@6.0.3) - stylelint-config-standard-scss@17.0.0(postcss@8.5.25)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)): + stylelint-config-standard-scss@17.0.0(postcss@8.5.26)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)): dependencies: stylelint: 17.14.1(supports-color@5.5.0)(typescript@6.0.3) - stylelint-config-recommended-scss: 17.0.1(postcss@8.5.25)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) + stylelint-config-recommended-scss: 17.0.1(postcss@8.5.26)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) stylelint-config-standard: 40.0.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) optionalDependencies: - postcss: 8.5.25 + postcss: 8.5.26 stylelint-config-standard@40.0.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)): dependencies: @@ -10850,8 +11259,8 @@ snapshots: micromatch: 4.0.8 normalize-path: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.25 - postcss-safe-parser: 7.0.1(postcss@8.5.25) + postcss: 8.5.26 + postcss-safe-parser: 7.0.1(postcss@8.5.26) postcss-selector-parser: 7.1.4 postcss-value-parser: 4.2.0 string-width: 8.2.2 @@ -10898,7 +11307,7 @@ snapshots: lodash.merge: 4.6.2 mustache: 4.2.0 prettysize: 2.0.0 - svgo: 2.8.2 + svgo: 2.8.3 vinyl: 2.2.1 winston: 3.19.0 xpath: 0.0.34 @@ -10906,7 +11315,7 @@ snapshots: svg-tags@1.0.0: {} - svgo@2.8.2: + svgo@2.8.3: dependencies: commander: 7.2.0 css-select: 4.3.0 @@ -10947,7 +11356,7 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - tdigest@0.1.2: + tdigest@0.1.3: dependencies: bintrees: 1.0.2 @@ -11045,6 +11454,12 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -11107,10 +11522,10 @@ snapshots: unpipe@1.0.0: {} - unplugin-dts@1.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.2))(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)): + unplugin-dts@1.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.4.0))(esbuild@0.28.2)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)): dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.61.1) - '@volar/typescript': 2.4.28 + '@volar/typescript': 2.4.28(typescript@6.0.3) compare-versions: 6.1.1 debug: 4.4.3(supports-color@10.2.2) kolorist: 1.8.0 @@ -11119,11 +11534,11 @@ snapshots: typescript: 6.0.3 unplugin: 2.3.11 optionalDependencies: - '@microsoft/api-extractor': 7.56.2(@types/node@26.1.2) - esbuild: 0.28.1 + '@microsoft/api-extractor': 7.56.2(@types/node@26.4.0) + esbuild: 0.28.2 rolldown: 1.2.1 rollup: 4.61.1 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) transitivePeerDependencies: - supports-color @@ -11185,13 +11600,13 @@ snapshots: remove-trailing-separator: 1.1.0 replace-ext: 1.0.1 - vite-plugin-dts@5.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.2))(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)): + vite-plugin-dts@5.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.4.0))(esbuild@0.28.2)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)): dependencies: - unplugin-dts: 1.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.2))(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + unplugin-dts: 1.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.4.0))(esbuild@0.28.2)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) optionalDependencies: - '@microsoft/api-extractor': 7.56.2(@types/node@26.1.2) + '@microsoft/api-extractor': 7.56.2(@types/node@26.4.0) rollup: 4.61.1 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) transitivePeerDependencies: - '@rspack/core' - '@vue/language-core' @@ -11201,29 +11616,29 @@ snapshots: - typescript - webpack - vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0): + vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.25 - rolldown: 1.2.1 + postcss: 8.5.26 + rolldown: 1.2.6 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.1.2 - esbuild: 0.28.1 + '@types/node': 26.4.0 + esbuild: 0.28.2 fsevents: 2.3.3 - sass: 1.102.0 - sass-embedded: 1.100.0 + sass: 1.103.1 + sass-embedded: 1.103.1 - vitest@4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)): + vitest@4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)): dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 es-module-lexer: 2.3.1 expect-type: 1.4.0 magic-string: 0.30.21 @@ -11235,13 +11650,13 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.1.2 - '@vitest/browser-playwright': 4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) - '@vitest/coverage-v8': 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) - '@vitest/ui': 4.1.10(vitest@4.1.10) + '@types/node': 26.4.0 + '@vitest/browser-playwright': 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11) + '@vitest/coverage-v8': 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) + '@vitest/ui': 4.1.11(vitest@4.1.11) jsdom: 30.0.1(canvas@3.2.3) transitivePeerDependencies: - msw @@ -11394,7 +11809,7 @@ snapshots: dependencies: signal-exit: 4.1.0 - ws@8.21.1: {} + ws@8.21.2: {} wsl-utils@0.1.0: dependencies: diff --git a/frontend/resources/images/cursors/draw-add.svg b/frontend/resources/images/cursors/draw-add.svg new file mode 100644 index 0000000000..a2113432fb --- /dev/null +++ b/frontend/resources/images/cursors/draw-add.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"> + <g transform="scale(0.76190476)"> + <path d="M17.2916 13.4999C17.7886 13.4999 18.1919 13.9033 18.192 14.4003V15.8075H19.5992C20.0962 15.8075 20.4995 16.2109 20.4996 16.7079V17.2919C20.4996 17.7889 20.0963 18.1923 19.5992 18.1923H18.192V19.5995C18.192 20.0965 17.7887 20.4999 17.2916 20.4999H16.7076C16.2108 20.4997 15.8073 20.0964 15.8073 19.5995V18.1923H14.4C13.9031 18.1921 13.4996 17.7888 13.4996 17.2919V16.7079C13.4997 16.211 13.9032 15.8077 14.4 15.8075H15.8073V14.4003C15.8073 13.9034 16.2108 13.5001 16.7076 13.4999H17.2916ZM0.88147 -0.0987549L1.00256 -0.0782471L1.99182 0.146362C2.3212 0.21982 2.65134 0.291628 2.98108 0.361206C4.00487 0.577222 5.31171 0.836914 6.47229 1.01648C7.39815 1.15971 8.48521 1.32758 9.51331 1.64441C10.5453 1.96253 11.6032 2.45468 12.4391 3.30359C14.1063 4.99746 14.7467 6.98154 14.6783 9.37097L15.8883 10.5809C16.4936 11.1862 16.4936 12.168 15.8883 12.7733L12.7731 15.8885C12.1678 16.4938 11.186 16.4938 10.5807 15.8885L9.37073 14.6786C6.98134 14.747 4.99719 14.1065 3.30334 12.4393C2.45441 11.6035 1.96229 10.5455 1.64417 9.51355C1.32731 8.48543 1.15947 7.39842 1.01624 6.47253C0.836669 5.31192 0.576975 4.00516 0.360962 2.98132C0.221743 2.3215 0.0738358 1.66228 -0.0784912 1.00281C-0.218776 0.394559 0.283552 -0.155568 0.88147 -0.0987549Z" fill="white"/> + <path d="M17.2922 14.0001C17.513 14.0001 17.6923 14.1788 17.6926 14.3995V16.3077H19.5998C19.8205 16.3077 19.9999 16.4865 20.0002 16.7072V17.2921C20.0002 17.513 19.8207 17.6925 19.5998 17.6925H17.6926V19.5997C17.6926 19.8206 17.5131 20.0001 17.2922 20.0001H16.7072C16.4865 19.9999 16.3078 19.8205 16.3078 19.5997V17.6925H14.3996C14.1789 17.6923 14.0002 17.5129 14.0002 17.2921V16.7072C14.0004 16.4866 14.179 16.308 14.3996 16.3077H16.3078V14.3995C16.308 14.179 16.4867 14.0004 16.7072 14.0001H17.2922ZM0.889832 0.408325C1.55119 0.561087 2.21384 0.710545 2.87811 0.850708C3.90528 1.06744 5.22258 1.32923 6.39667 1.51086C7.32828 1.65499 8.37875 1.81778 9.36639 2.12219C10.3562 2.42732 11.3274 2.88674 12.0832 3.65442C13.6878 5.28471 14.2849 7.19577 14.174 9.57336L15.5353 10.9347C15.9452 11.3448 15.9453 12.01 15.5353 12.42L12.4201 15.5353C12.0101 15.9453 11.3448 15.9452 10.9348 15.5353L9.57343 14.174C7.19581 14.2849 5.28478 13.6877 3.65448 12.0831C2.88678 11.3274 2.42739 10.3562 2.12225 9.36633C1.81784 8.37867 1.65505 7.32823 1.51093 6.39661C1.32929 5.2225 1.06749 3.90523 0.850769 2.87805C0.710614 2.21378 0.56115 1.55113 0.408386 0.889771C0.342017 0.601058 0.601117 0.34194 0.889832 0.408325ZM7.1496 6.35266C7.77157 6.05022 8.54221 6.15652 9.05878 6.67297C9.71018 7.32455 9.71021 8.38079 9.05878 9.03235C8.40723 9.6839 7.35102 9.68379 6.6994 9.03235C6.18548 8.51832 6.07823 7.7533 6.37518 7.13293L1.93964 2.71301C2.1554 3.73878 2.41539 5.04925 2.59784 6.22864C2.74397 7.17322 2.8961 8.14467 3.17303 9.04309C3.44934 9.93927 3.83806 10.7193 4.42694 11.299C5.85147 12.701 7.63567 13.1258 9.54999 13.0695C9.83791 13.0611 10.1196 13.1647 10.3234 13.3683L11.6769 14.7218L14.7219 11.6769L13.3683 10.3234C13.1648 10.1196 13.0611 9.83786 13.0695 9.54993C13.1258 7.63562 12.701 5.8514 11.299 4.42688C10.7194 3.838 9.93933 3.44928 9.04315 3.17297C8.14473 2.89604 7.17327 2.74391 6.2287 2.59778C5.05377 2.41601 3.74882 2.15681 2.72479 1.94153L7.1496 6.35266Z" fill="black"/> + </g> +</svg> diff --git a/frontend/resources/images/cursors/draw-node.svg b/frontend/resources/images/cursors/draw-node.svg new file mode 100644 index 0000000000..5d6290c6bc --- /dev/null +++ b/frontend/resources/images/cursors/draw-node.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"> + <g transform="scale(0.76190476)"> + <path d="M16.9996 13.7997C18.7669 13.7997 20.1997 15.2326 20.1998 16.9999C20.1998 18.7672 18.7669 20.2001 16.9996 20.2001C15.2325 20.1999 13.7994 18.7671 13.7994 16.9999C13.7995 15.2328 15.2326 13.7999 16.9996 13.7997ZM0.88147 -0.0987549L1.00256 -0.0782471L1.99182 0.146362C2.3212 0.21982 2.65134 0.291628 2.98108 0.361206C4.00487 0.577222 5.31171 0.836914 6.47229 1.01648C7.39815 1.15971 8.48521 1.32758 9.51331 1.64441C10.5453 1.96253 11.6032 2.45468 12.4391 3.30359C14.1063 4.99746 14.7467 6.98154 14.6783 9.37097L15.8883 10.5809C16.4936 11.1862 16.4936 12.168 15.8883 12.7733L12.7731 15.8885C12.1678 16.4938 11.186 16.4938 10.5807 15.8885L9.37073 14.6786C6.98134 14.747 4.99719 14.1065 3.30334 12.4393C2.45441 11.6035 1.96229 10.5455 1.64417 9.51355C1.32731 8.48543 1.15947 7.39842 1.01624 6.47253C0.836669 5.31192 0.576975 4.00516 0.360962 2.98132C0.221743 2.3215 0.0738358 1.66228 -0.0784912 1.00281C-0.218776 0.394559 0.283552 -0.155568 0.88147 -0.0987549Z" fill="white"/> + <path d="M17.0002 14.2999C18.4913 14.3 19.7004 15.509 19.7004 17.0001C19.7003 18.4911 18.4912 19.7002 17.0002 19.7003C15.5091 19.7003 14.3001 18.4912 14.3 17.0001C14.3 15.5089 15.509 14.2999 17.0002 14.2999ZM17.0002 15.7003C16.2822 15.7003 15.7004 16.2821 15.7004 17.0001C15.7005 17.718 16.2823 18.2999 17.0002 18.2999C17.718 18.2998 18.2999 17.7179 18.3 17.0001C18.3 16.2822 17.7181 15.7004 17.0002 15.7003ZM0.889821 0.408319C1.55118 0.561081 2.21383 0.710539 2.8781 0.850702C3.90527 1.06743 5.22257 1.32922 6.39666 1.51086C7.32827 1.65498 8.37874 1.81777 9.36638 2.12219C10.3562 2.42732 11.3274 2.88673 12.0832 3.65441C13.6878 5.2847 14.2849 7.19577 14.174 9.57336L15.5353 10.9347C15.9452 11.3447 15.9453 12.01 15.5353 12.42L12.4201 15.5353C12.0101 15.9453 11.3448 15.9452 10.9347 15.5353L9.57342 14.1739C7.1958 14.2849 5.28477 13.6877 3.65447 12.0831C2.88677 11.3274 2.42738 10.3562 2.12224 9.36633C1.81783 8.37867 1.65504 7.32822 1.51092 6.3966C1.32928 5.2225 1.06748 3.90522 0.850759 2.87805C0.710604 2.21377 0.56114 1.55112 0.408376 0.889764C0.342007 0.601052 0.601107 0.341934 0.889821 0.408319ZM7.14959 6.35266C7.77156 6.05021 8.5422 6.15651 9.05877 6.67297C9.71017 7.32454 9.7102 8.38079 9.05877 9.03234C8.40722 9.68389 7.35101 9.68378 6.69939 9.03234C6.18547 8.51831 6.07822 7.75329 6.37517 7.13293L1.93963 2.71301C2.15539 3.73878 2.41538 5.04924 2.59783 6.22863C2.74396 7.17321 2.89609 8.14466 3.17302 9.04308C3.44933 9.93926 3.83805 10.7193 4.42693 11.2989C5.85146 12.701 7.63566 13.1258 9.54998 13.0695C9.8379 13.0611 10.1196 13.1647 10.3234 13.3683L11.6769 14.7218L14.7219 11.6769L13.3683 10.3234C13.1648 10.1196 13.0611 9.83785 13.0695 9.54992C13.1258 7.63561 12.701 5.8514 11.299 4.42687C10.7194 3.838 9.93932 3.44927 9.04314 3.17297C8.14472 2.89603 7.17326 2.7439 6.22869 2.59777C5.05376 2.41601 3.74881 2.1568 2.72478 1.94152L7.14959 6.35266Z" fill="black"/> + </g> +</svg> diff --git a/frontend/resources/images/cursors/draw-remove.svg b/frontend/resources/images/cursors/draw-remove.svg new file mode 100644 index 0000000000..1d430ca8e5 --- /dev/null +++ b/frontend/resources/images/cursors/draw-remove.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"> + <g transform="scale(0.76190476)"> + <path d="M19.5992 15.8075C20.0962 15.8075 20.4995 16.211 20.4996 16.7079V17.2919C20.4996 17.7889 20.0963 18.1923 19.5992 18.1923H14.4C13.9031 18.1921 13.4996 17.7888 13.4996 17.2919V16.7079C13.4998 16.2111 13.9032 15.8077 14.4 15.8075H19.5992ZM0.88147 -0.0987549L1.00256 -0.0782471L1.99182 0.146362C2.3212 0.21982 2.65134 0.291628 2.98108 0.361206C4.00487 0.577222 5.31171 0.836914 6.47229 1.01648C7.39815 1.15971 8.48521 1.32758 9.51331 1.64441C10.5453 1.96253 11.6032 2.45468 12.4391 3.30359C14.1063 4.99746 14.7467 6.98154 14.6783 9.37097L15.8883 10.5809C16.4936 11.1862 16.4936 12.168 15.8883 12.7733L12.7731 15.8885C12.1678 16.4938 11.186 16.4938 10.5807 15.8885L9.37073 14.6786C6.98134 14.747 4.99719 14.1065 3.30334 12.4393C2.45441 11.6035 1.96229 10.5455 1.64417 9.51355C1.32731 8.48543 1.15947 7.39842 1.01624 6.47253C0.836669 5.31192 0.576975 4.00516 0.360962 2.98132C0.221743 2.3215 0.0738358 1.66228 -0.0784912 1.00281C-0.218776 0.394559 0.283552 -0.155568 0.88147 -0.0987549Z" fill="white"/> + <path d="M19.5998 16.3077C19.8207 16.3077 20.0002 16.4872 20.0002 16.7081V17.2921C20.0002 17.513 19.8207 17.6925 19.5998 17.6925H14.3996C14.1789 17.6923 14.0002 17.5129 14.0002 17.2921V16.7081C14.0002 16.4874 14.1789 16.308 14.3996 16.3077H19.5998ZM0.889832 0.408325C1.55119 0.561087 2.21384 0.710545 2.87811 0.850708C3.90528 1.06744 5.22258 1.32923 6.39667 1.51086C7.32828 1.65499 8.37875 1.81778 9.36639 2.12219C10.3562 2.42732 11.3274 2.88674 12.0832 3.65442C13.6878 5.28471 14.2849 7.19577 14.174 9.57336L15.5353 10.9347C15.9452 11.3448 15.9453 12.01 15.5353 12.42L12.4201 15.5353C12.0101 15.9453 11.3448 15.9452 10.9348 15.5353L9.57343 14.174C7.19581 14.2849 5.28478 13.6877 3.65448 12.0831C2.88678 11.3274 2.42739 10.3562 2.12225 9.36633C1.81784 8.37867 1.65505 7.32823 1.51093 6.39661C1.32929 5.2225 1.06749 3.90523 0.850769 2.87805C0.710614 2.21378 0.56115 1.55113 0.408386 0.889771C0.342017 0.601058 0.601117 0.34194 0.889832 0.408325ZM7.1496 6.35266C7.77157 6.05022 8.54221 6.15652 9.05878 6.67297C9.71018 7.32455 9.71021 8.38079 9.05878 9.03235C8.40723 9.6839 7.35102 9.68379 6.6994 9.03235C6.18548 8.51832 6.07823 7.7533 6.37518 7.13293L1.93964 2.71301C2.1554 3.73878 2.41539 5.04925 2.59784 6.22864C2.74397 7.17322 2.8961 8.14467 3.17303 9.04309C3.44934 9.93927 3.83806 10.7193 4.42694 11.299C5.85147 12.701 7.63567 13.1258 9.54999 13.0695C9.83791 13.0611 10.1196 13.1647 10.3234 13.3683L11.6769 14.7218L14.7219 11.6769L13.3683 10.3234C13.1648 10.1196 13.0611 9.83786 13.0695 9.54993C13.1258 7.63562 12.701 5.8514 11.299 4.42688C10.7194 3.838 9.93933 3.44928 9.04315 3.17297C8.14473 2.89604 7.17327 2.74391 6.2287 2.59778C5.05377 2.41601 3.74882 2.15681 2.72479 1.94153L7.1496 6.35266Z" fill="black"/> + </g> +</svg> diff --git a/frontend/resources/images/cursors/draw.svg b/frontend/resources/images/cursors/draw.svg new file mode 100644 index 0000000000..97e139d4e4 --- /dev/null +++ b/frontend/resources/images/cursors/draw.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"> + <g transform="scale(0.94117647)"> + <path d="M0.88185 -0.0985746L1.00294 -0.0780668L1.9922 0.146543C2.32172 0.22003 2.65158 0.291781 2.98146 0.361386C4.00537 0.577427 5.31198 0.837091 6.47267 1.01666C7.39865 1.15991 8.48547 1.32767 9.51369 1.64459C10.5458 1.96275 11.6036 2.45467 12.4395 3.30377C14.1067 4.9977 14.7472 6.98162 14.6787 9.37115L15.8887 10.5811C16.494 11.1864 16.494 12.1682 15.8887 12.7735L12.7735 15.8887C12.1681 16.494 11.1864 16.494 10.5811 15.8887L9.37111 14.6788C6.98155 14.7472 4.99766 14.1068 3.30372 12.4395C2.4546 11.6036 1.96271 10.5458 1.64455 9.51373C1.32763 8.48551 1.15987 7.3987 1.01662 6.47271C0.837047 5.31203 0.577372 4.00542 0.361342 2.9815C0.222105 2.32158 0.0742392 1.66256 -0.0781112 1.00299C-0.218667 0.394482 0.283656 -0.15568 0.88185 -0.0985746Z" fill="white"/> + <path d="M2.87793 0.850614C3.90513 1.06735 5.22231 1.32912 6.39648 1.51077C7.32814 1.6549 8.37849 1.81766 9.36621 2.1221C10.3561 2.42723 11.3272 2.88659 12.083 3.65433C13.6877 5.28465 14.2847 7.1956 14.1738 9.57327L15.5352 10.9346C15.9452 11.3447 15.9452 12.0099 15.5352 12.42L12.4199 15.5352C12.0099 15.9452 11.3446 15.9452 10.9346 15.5352L9.57324 14.1739C7.19554 14.2848 5.28463 13.6878 3.65429 12.083C2.88654 11.3272 2.4272 10.3561 2.12207 9.36624C1.81763 8.37852 1.65487 7.32817 1.51074 6.39651C1.32909 5.22234 1.06731 3.90516 0.850582 2.87796C0.710427 2.21368 0.5614 1.55158 0.408635 0.890223C0.341875 0.601198 0.601167 0.341906 0.890192 0.408666C1.55155 0.561427 2.21365 0.710451 2.87793 0.850614ZM7.14941 6.35257C7.7714 6.05007 8.542 6.1564 9.05859 6.67288C9.71016 7.32445 9.71009 8.38064 9.05859 9.03226C8.40699 9.68386 7.35082 9.68386 6.69922 9.03226C6.18528 8.51821 6.07801 7.75322 6.375 7.13284L1.93945 2.71292C2.15522 3.73871 2.41519 5.04909 2.59765 6.22854C2.74379 7.17318 2.8959 8.14452 3.17285 9.043C3.44916 9.93925 3.83784 10.7192 4.42675 11.2989C5.85131 12.701 7.63521 13.1262 9.5496 13.0698C9.83784 13.0613 10.1199 13.1648 10.3238 13.3687L11.6768 14.7217L14.7217 11.6768L13.3687 10.3238C13.1648 10.1199 13.0613 9.83787 13.0698 9.54963C13.1261 7.63525 12.701 5.85134 11.2988 4.42679C10.7192 3.83787 9.93923 3.44919 9.04296 3.17288C8.14449 2.89593 7.17315 2.74382 6.22851 2.59768C5.0535 2.41591 3.74866 2.15672 2.72461 1.94143L7.14941 6.35257Z" fill="black"/> + </g> +</svg> diff --git a/frontend/resources/images/cursors/move-add.svg b/frontend/resources/images/cursors/move-add.svg new file mode 100644 index 0000000000..3103c58d71 --- /dev/null +++ b/frontend/resources/images/cursors/move-add.svg @@ -0,0 +1,8 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"> + <g transform="scale(0.76190476)"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M15.2917 11.4996C15.7886 11.4996 16.1919 11.9031 16.1921 12.4V13.8072H17.5993C18.0962 13.8072 18.4995 14.2107 18.4997 14.7076V15.2916C18.4997 15.7886 18.0964 16.1919 17.5993 16.1919H16.1921V17.5992C16.1921 18.0962 15.7888 18.4996 15.2917 18.4996H14.7077C14.2108 18.4994 13.8073 18.0961 13.8073 17.5992V16.1919H12.4001C11.9032 16.1918 11.4997 15.7885 11.4997 15.2916V14.7076C11.5 14.2108 11.9033 13.8073 12.4001 13.8072H13.8073V12.4C13.8076 11.9032 14.2109 11.4997 14.7077 11.4996H15.2917Z" fill="white"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M-0.0266525 1.14995C-0.31627 0.454553 0.319614 -0.242906 1.01143 -0.0727049L1.15011 -0.0268064L17.2966 6.70073C18.0785 7.02731 18.0168 8.1558 17.2038 8.39507L10.3933 10.3931L8.39522 17.2037C8.15608 18.0168 7.02745 18.0786 6.70089 17.2964L-0.0266525 1.14995Z" fill="white"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M0.434899 0.957365C0.297544 0.627676 0.627671 0.297533 0.95736 0.434904L17.1038 7.16244C17.4513 7.30772 17.4234 7.80926 17.0619 7.91537L9.99056 9.99057L7.91537 17.0619C7.80926 17.4234 7.3077 17.4514 7.16244 17.1038L0.434899 0.957365ZM7.41634 14.8538L8.94955 9.62826C9.04541 9.30163 9.30162 9.04541 9.62826 8.94955L14.8538 7.41635L2.10384 2.10385L7.41634 14.8538Z" fill="black"/> + <path d="M15.2923 12.0004C15.5131 12.0004 15.6926 12.179 15.6927 12.3998V14.308H17.5999C17.8208 14.308 18.0002 14.4866 18.0003 14.7074V15.2924C18.0003 15.5133 17.8209 15.6927 17.5999 15.6927H15.6927V17.6C15.6927 17.8209 15.5132 18.0004 15.2923 18.0004H14.7074C14.4866 18.0002 14.308 17.8208 14.308 17.6V15.6927H12.3997C12.179 15.6926 12.0003 15.5132 12.0003 15.2924V14.7074C12.0005 14.4867 12.1791 14.3081 12.3997 14.308H14.308V12.3998C14.3081 12.1791 14.4867 12.0005 14.7074 12.0004H15.2923Z" fill="black"/> + </g> +</svg> diff --git a/frontend/resources/images/cursors/move-copy.svg b/frontend/resources/images/cursors/move-copy.svg new file mode 100644 index 0000000000..4c6038f11b --- /dev/null +++ b/frontend/resources/images/cursors/move-copy.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"> + <g transform="scale(0.76190476)"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M2.97335 1.14995C2.68373 0.454553 3.31961 -0.242906 4.01143 -0.0727049L4.15011 -0.0268064L20.2966 6.70073C21.0785 7.02732 21.0168 8.15579 20.2038 8.39507L17.5954 9.15972C17.4941 9.26352 17.3646 9.34774 17.2038 9.39507L13.3542 10.524L11.3952 17.2037C11.1561 18.0168 10.0275 18.0786 9.70089 17.2964L9.09054 15.8316L8.39522 18.2037C8.15608 19.0168 7.02745 19.0786 6.70089 18.2964L-0.0266525 2.14995C-0.31627 1.45455 0.319614 0.757094 1.01143 0.927295L1.15011 0.973194L3.26632 1.85503L2.97335 1.14995Z" fill="white"/> + <path d="M0.434906 1.95752C0.297569 1.62792 0.627771 1.29704 0.957367 1.43408L17.1039 8.1626C17.4513 8.30789 17.4234 8.80942 17.0619 8.91553L9.99057 10.9907L7.91537 18.062C7.80921 18.4238 7.30745 18.451 7.16245 18.103L0.434906 1.95752ZM10.9154 17.062C10.8092 17.4235 10.3078 17.4514 10.1624 17.104L9.31381 15.0688L9.806 13.3901L10.4164 14.854L11.5296 11.0591L12.7845 10.6909L10.9154 17.062ZM7.41635 15.854L8.94955 10.6284C9.04541 10.3018 9.30163 10.0456 9.62827 9.94971L14.8539 8.4165L2.10385 3.104L7.41635 15.854ZM3.43491 0.95752C3.29755 0.627831 3.62768 0.297688 3.95737 0.435059L20.1039 7.1626C20.4513 7.3079 20.4234 7.80942 20.0619 7.91553L17.847 8.56494C17.8603 8.21699 17.6817 7.86151 17.2962 7.70068L17.1263 7.62939L17.8539 7.4165L5.10385 2.104L5.36362 2.729L3.92221 2.12842L3.43491 0.95752Z" fill="black"/> + </g> +</svg> diff --git a/frontend/resources/images/cursors/move-curve.svg b/frontend/resources/images/cursors/move-curve.svg new file mode 100644 index 0000000000..f898b6cf33 --- /dev/null +++ b/frontend/resources/images/cursors/move-curve.svg @@ -0,0 +1,7 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"> + <g transform="scale(0.76190476)"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M17.072 10.9283C17.6585 10.3534 18.5993 10.358 19.1814 10.939C19.7668 11.5248 19.7679 12.4744 19.1823 13.0601C18.6218 13.6206 17.7286 13.6436 17.1394 13.1314C17.0389 13.1452 16.9321 13.1632 16.821 13.1861C16.0895 13.3369 15.1513 13.6976 14.3034 14.5455C13.4556 15.3934 13.0949 16.3315 12.9441 17.063C12.9211 17.1743 12.9033 17.2812 12.8894 17.3814C13.4016 17.9707 13.3786 18.8638 12.8181 19.4244C12.2324 20.0099 11.2827 20.0098 10.697 19.4244C10.1162 18.8425 10.1119 17.9015 10.6862 17.315C10.7082 17.1044 10.738 16.8696 10.7898 16.6187C11.0062 15.5692 11.5302 14.2094 12.7487 12.9908C13.9673 11.7722 15.3271 11.2482 16.3767 11.0318C16.6275 10.9801 16.862 10.9501 17.072 10.9283Z" fill="white"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M-0.0266525 1.14995C-0.31627 0.454553 0.319614 -0.242906 1.01143 -0.0727049L1.15011 -0.0268064L17.2966 6.70073C18.0785 7.02732 18.0168 8.15579 17.2038 8.39507L10.3933 10.3931L8.39522 17.2037C8.15608 18.0168 7.02745 18.0786 6.70089 17.2964L-0.0266525 1.14995Z" fill="white"/> + <path d="M17.4141 11.292C17.8043 10.902 18.4376 10.9026 18.8282 11.292C19.2187 11.6826 19.2187 12.3166 18.8282 12.7071C18.4376 13.0972 17.8045 13.0974 17.4141 12.7071C17.3827 12.6756 17.3536 12.6419 17.3272 12.6075C17.1485 12.6238 16.9436 12.6504 16.7207 12.6963C15.9168 12.8621 14.8825 13.2602 13.9502 14.1924C13.0183 15.1245 12.6199 16.1581 12.4541 16.962C12.4081 17.1853 12.3806 17.3914 12.3643 17.5704C12.3988 17.5968 12.4333 17.6248 12.4649 17.6563C12.8552 18.0467 12.8551 18.6798 12.4649 19.0704C12.0744 19.4609 11.4414 19.4608 11.0508 19.0704C10.661 18.6798 10.6605 18.0466 11.0508 17.6563C11.0859 17.6213 11.1236 17.5903 11.1622 17.5616C11.1806 17.3185 11.2148 17.0328 11.2793 16.7198C11.4809 15.7425 11.9673 14.4781 13.1016 13.3438C14.2359 12.2095 15.5003 11.7231 16.4776 11.5215C16.7903 11.4571 17.0755 11.4237 17.3184 11.4053C17.3474 11.3663 17.3787 11.3275 17.4141 11.292ZM0.434617 0.957086C0.297691 0.627629 0.62762 0.297673 0.957078 0.434626L17.1036 7.16216C17.4511 7.30744 17.4232 7.80898 17.0616 7.91509L9.99028 9.99029L7.91509 17.0616C7.80897 17.4232 7.30742 17.4511 7.16216 17.1036L0.434617 0.957086ZM7.41606 14.8536L8.94927 9.62798C9.04515 9.3015 9.30149 9.04515 9.62798 8.94927L14.8536 7.41607L2.10356 2.10357L7.41606 14.8536Z" fill="black"/> + </g> +</svg> diff --git a/frontend/resources/images/cursors/move-handles.svg b/frontend/resources/images/cursors/move-handles.svg new file mode 100644 index 0000000000..852cfe59b9 --- /dev/null +++ b/frontend/resources/images/cursors/move-handles.svg @@ -0,0 +1,8 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"> + <g transform="scale(0.76190476)"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M17.6599 10.5835C18.2457 9.9979 19.1952 9.9979 19.781 10.5835C20.3662 11.1693 20.3664 12.119 19.781 12.7046C19.5027 12.9829 19.1422 13.124 18.78 13.1382L17.5485 14.3697C17.8471 15.2433 17.6505 16.2501 16.9528 16.9478C16.2552 17.6455 15.2483 17.842 14.3747 17.5435L13.1433 18.775C13.1291 19.1372 12.988 19.4976 12.7097 19.7759C12.1241 20.3614 11.1744 20.3612 10.5886 19.7759C10.0029 19.1902 10.0029 18.2406 10.5886 17.6548C10.8668 17.3766 11.2265 17.2336 11.5886 17.2193L12.82 15.9878C12.5222 15.1145 12.7194 14.109 13.4167 13.4117C14.1141 12.7144 15.1196 12.5171 15.9929 12.815L17.2243 11.5835C17.2386 11.2215 17.3816 10.8618 17.6599 10.5835Z" fill="white"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M-0.0266525 1.14995C-0.31627 0.454553 0.319614 -0.242906 1.01143 -0.0727049L1.15011 -0.0268064L17.2966 6.70073C18.0785 7.02732 18.0168 8.15579 17.2038 8.39507L10.3933 10.3931L8.39522 17.2037C8.15608 18.0168 7.02745 18.0786 6.70089 17.2964L-0.0266525 1.14995Z" fill="white"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M18.013 10.9369C18.4035 10.5464 19.0366 10.5464 19.4271 10.9369C19.8173 11.3274 19.8175 11.9605 19.4271 12.3509C19.1958 12.5822 18.8791 12.6748 18.5785 12.6322L16.9574 14.2542C17.351 15.0082 17.2312 15.9599 16.598 16.5931C15.9647 17.2263 15.0131 17.3461 14.2591 16.9525L12.637 18.5736C12.6797 18.8742 12.587 19.191 12.3558 19.4222C11.9654 19.8125 11.3322 19.8124 10.9417 19.4222C10.5513 19.0317 10.5512 18.3977 10.9417 18.0072C11.1729 17.7762 11.489 17.6834 11.7894 17.7259L13.4105 16.1048C13.0168 15.3508 13.1366 14.3983 13.7699 13.765C14.4032 13.1317 15.3557 13.0119 16.1097 13.4056L17.7308 11.7845C17.6882 11.4839 17.7818 11.1681 18.013 10.9369ZM15.7494 14.6136C15.4369 14.3014 14.9308 14.3013 14.6185 14.6136C14.3062 14.926 14.3063 15.4321 14.6185 15.7445C14.9309 16.0569 15.4369 16.0568 15.7494 15.7445C16.0618 15.4321 16.0618 14.926 15.7494 14.6136Z" fill="black"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M0.434899 0.957365C0.297544 0.627676 0.627671 0.297533 0.95736 0.434904L17.1038 7.16244C17.4513 7.30772 17.4234 7.80926 17.0619 7.91537L9.99056 9.99057L7.91537 17.0619C7.80926 17.4234 7.3077 17.4514 7.16244 17.1038L0.434899 0.957365ZM7.41634 14.8538L8.94955 9.62826C9.04541 9.30163 9.30162 9.04541 9.62826 8.94955L14.8538 7.41635L2.10384 2.10385L7.41634 14.8538Z" fill="black"/> + </g> +</svg> diff --git a/frontend/resources/images/cursors/move-move.svg b/frontend/resources/images/cursors/move-move.svg new file mode 100644 index 0000000000..beb6585cc0 --- /dev/null +++ b/frontend/resources/images/cursors/move-move.svg @@ -0,0 +1,8 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"> + <g transform="scale(0.76190476)"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M16.4148 10.9996C16.8049 11.3899 16.8046 12.0231 16.4148 12.4136C16.2192 12.6091 15.962 12.7069 15.7058 12.7066L15.7067 13.7076H16.7067C16.7067 13.4517 16.8046 13.1948 16.9997 12.9996C17.3902 12.6094 18.0234 12.6095 18.4138 12.9996L20.1208 14.7066L18.4138 16.4136C18.0234 16.8037 17.3902 16.8047 16.9997 16.4146C16.8047 16.2194 16.7067 15.9624 16.7067 15.7066H15.7067V16.7066C15.9626 16.7065 16.2185 16.8045 16.4138 16.9996L16.4148 17.0005C16.8046 17.3911 16.8039 18.0233 16.4138 18.4136L14.7067 20.1207L12.9997 18.4136C12.6096 18.0231 12.6094 17.39 12.9997 16.9996C13.1949 16.8044 13.451 16.7066 13.7067 16.7066V15.7066H12.7067C12.7068 15.9625 12.609 16.2184 12.4138 16.4136C12.0233 16.804 11.3903 16.8042 10.9997 16.4136L9.29268 14.7066L10.9997 12.9996C11.3901 12.6093 12.0233 12.6096 12.4138 12.9996C12.609 13.1948 12.7068 13.4516 12.7067 13.7076H13.7067V12.7076C13.451 12.7075 13.1949 12.6097 12.9997 12.4146C12.6094 12.0242 12.6097 11.3911 12.9997 11.0005L14.7077 9.29253L16.4148 10.9996Z" fill="white"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M-0.0266525 1.14995C-0.31627 0.454553 0.319614 -0.242906 1.01143 -0.0727049L1.15011 -0.0268064L17.2966 6.70073C18.0785 7.02731 18.0168 8.1558 17.2038 8.39507L10.3933 10.3931L8.39522 17.2037C8.15608 18.0168 7.02745 18.0786 6.70089 17.2964L-0.0266525 1.14995Z" fill="white"/> + <path d="M16.061 11.354C16.2558 11.5493 16.2561 11.8659 16.061 12.061C15.8659 12.2561 15.5493 12.2558 15.354 12.061L15.2075 11.9145V14.2075H17.5005L17.354 14.061C17.1588 13.8657 17.1588 13.5492 17.354 13.354C17.5492 13.1587 17.8657 13.1587 18.061 13.354L19.4145 14.7075L18.061 16.061C17.8659 16.2561 17.5493 16.2558 17.354 16.061C17.1588 15.8657 17.1588 15.5492 17.354 15.354L17.5005 15.2075H15.2075V17.5005L15.354 17.354C15.5492 17.1587 15.8657 17.1587 16.061 17.354C16.2558 17.5493 16.2561 17.8659 16.061 18.061L14.7075 19.4145L13.354 18.061C13.1588 17.8657 13.1588 17.5492 13.354 17.354C13.5492 17.1587 13.8657 17.1587 14.061 17.354L14.2075 17.5005V15.2075H11.9145L12.061 15.354C12.2558 15.5493 12.2561 15.8659 12.061 16.061C11.8659 16.2561 11.5493 16.2558 11.354 16.061L10.0005 14.7075L11.354 13.354C11.5492 13.1587 11.8657 13.1587 12.061 13.354C12.2558 13.5493 12.2561 13.8659 12.061 14.061L11.9145 14.2075H14.2075V11.9145L14.061 12.061C13.8659 12.2561 13.5493 12.2558 13.354 12.061C13.1588 11.8657 13.1588 11.5492 13.354 11.354L14.7075 10.0005L16.061 11.354Z" fill="black"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M0.435036 0.957487C0.297681 0.627798 0.627808 0.297655 0.957497 0.435026L17.104 7.16257C17.4513 7.30791 17.4234 7.80934 17.062 7.91549L9.9907 9.99069L7.9155 17.062C7.80936 17.4234 7.30791 17.4513 7.16258 17.104L0.435036 0.957487ZM7.41648 14.854L8.94968 9.62839C9.04554 9.30175 9.30176 9.04553 9.6284 8.94967L14.854 7.41647L2.10398 2.10397L7.41648 14.854Z" fill="black"/> + </g> +</svg> diff --git a/frontend/resources/images/cursors/move-node.svg b/frontend/resources/images/cursors/move-node.svg new file mode 100644 index 0000000000..66f326aa90 --- /dev/null +++ b/frontend/resources/images/cursors/move-node.svg @@ -0,0 +1,7 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"> + <g transform="scale(0.76190476)"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M14.9997 11.7994C16.7669 11.7994 18.1997 13.2325 18.1999 14.9996C18.1999 16.7669 16.767 18.1998 14.9997 18.1998C13.2325 18.1996 11.7995 16.7668 11.7995 14.9996C11.7998 13.2326 13.2327 11.7995 14.9997 11.7994Z" fill="white"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M-0.0266525 1.14995C-0.31627 0.454553 0.319614 -0.242906 1.01143 -0.0727049L1.15011 -0.0268064L17.2966 6.70073C18.0785 7.02732 18.0168 8.15579 17.2038 8.39507L10.3933 10.3931L8.39522 17.2037C8.15608 18.0168 7.02745 18.0786 6.70089 17.2964L-0.0266525 1.14995Z" fill="white"/> + <path d="M15.0003 12.3002C16.4913 12.3004 17.7005 13.5093 17.7005 15.0004C17.7003 16.4913 16.4912 17.7004 15.0003 17.7006C13.5093 17.7006 12.3003 16.4914 12.3001 15.0004C12.3001 13.5092 13.5092 12.3002 15.0003 12.3002ZM0.434906 0.957397C0.297551 0.627708 0.627678 0.297565 0.957367 0.434937L17.1039 7.16248C17.4513 7.30775 17.4234 7.80929 17.0619 7.91541L9.99057 9.9906L7.91537 17.0619C7.80926 17.4235 7.30771 17.4514 7.16245 17.1039L0.434906 0.957397ZM15.0003 13.7006C14.2824 13.7006 13.7005 14.2824 13.7005 15.0004C13.7007 15.7182 14.2825 16.3002 15.0003 16.3002C15.718 16.3 16.3 15.7181 16.3001 15.0004C16.3001 14.2825 15.7181 13.7007 15.0003 13.7006ZM7.41635 14.8539L8.94955 9.6283C9.04541 9.30166 9.30163 9.04544 9.62827 8.94958L14.8539 7.41638L2.10385 2.10388L7.41635 14.8539Z" fill="black"/> + </g> +</svg> diff --git a/frontend/resources/images/cursors/move-remove.svg b/frontend/resources/images/cursors/move-remove.svg new file mode 100644 index 0000000000..022d749c07 --- /dev/null +++ b/frontend/resources/images/cursors/move-remove.svg @@ -0,0 +1,7 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"> + <g transform="scale(0.76190476)"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M-0.0266525 1.14995C-0.31627 0.454553 0.319614 -0.242906 1.01143 -0.0727049L1.15011 -0.0268064L17.2966 6.70073C18.0785 7.02731 18.0168 8.1558 17.2038 8.39507L10.3933 10.3931L8.39522 17.2037C8.15608 18.0168 7.02745 18.0786 6.70089 17.2964L-0.0266525 1.14995Z" fill="white"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M17.5993 13.8072C18.0962 13.8072 18.4995 14.2107 18.4997 14.7076V15.2916C18.4997 15.7886 18.0964 16.1919 17.5993 16.1919H12.4001C11.9032 16.1918 11.4997 15.7885 11.4997 15.2916V14.7076C11.5 14.2108 11.9033 13.8073 12.4001 13.8072H17.5993Z" fill="white"/> + <path d="M0.435028 0.95752C0.297673 0.627831 0.6278 0.297688 0.957489 0.435059L17.104 7.1626C17.4513 7.30795 17.4234 7.80937 17.062 7.91553L9.99069 9.99072L7.9155 17.062C7.80935 17.4235 7.30791 17.4513 7.16257 17.104L0.435028 0.95752ZM17.6001 14.3081C17.8209 14.3081 18.0004 14.4867 18.0005 14.7075V15.2925C18.0003 15.5133 17.8209 15.6919 17.6001 15.6919H12.3999C12.1791 15.6918 12.0006 15.5133 12.0005 15.2925V14.7075C12.0005 14.4867 12.1791 14.3082 12.3999 14.3081H17.6001ZM7.41647 14.854L8.94968 9.62842C9.04554 9.30178 9.30175 9.04557 9.62839 8.94971L14.854 7.4165L2.10397 2.104L7.41647 14.854Z" fill="black"/> + </g> +</svg> diff --git a/frontend/resources/images/cursors/move.svg b/frontend/resources/images/cursors/move.svg new file mode 100644 index 0000000000..223bb839a6 --- /dev/null +++ b/frontend/resources/images/cursors/move.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"> + <g transform="scale(0.76190476)"> + <path d="M-0.0270493 1.14959C-0.316667 0.454186 0.319218 -0.243272 1.01104 -0.0730711L1.14971 -0.0271726L17.2962 6.70037C18.0787 7.02675 18.0168 8.15559 17.2034 8.3947L10.3929 10.3927L8.39483 17.2033C8.15571 18.0167 7.02688 18.0786 6.70049 17.2961L-0.0270493 1.14959Z" fill="white"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M17.1034 7.1621C17.4514 7.3071 17.4239 7.80898 17.0622 7.91514L9.99053 9.99041L7.91526 17.0621C7.8091 17.4238 7.30723 17.4513 7.16222 17.1033L0.434499 0.957459C0.297119 0.627761 0.627881 0.296997 0.957579 0.434377L17.1034 7.1621ZM7.41631 14.8537L8.95 9.6278C9.04586 9.30117 9.30129 9.04573 9.62792 8.94987L14.8538 7.41619L2.10381 2.10369L7.41631 14.8537Z" fill="black"/> + </g> +</svg> diff --git a/frontend/resources/images/favicon-local.png b/frontend/resources/images/favicon-local.png new file mode 100644 index 0000000000..3e556a34ae Binary files /dev/null and b/frontend/resources/images/favicon-local.png differ diff --git a/frontend/resources/images/icons/handlers-equal.svg b/frontend/resources/images/icons/handlers-equal.svg new file mode 100644 index 0000000000..645e773d8c --- /dev/null +++ b/frontend/resources/images/icons/handlers-equal.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round"> + <path d="M3.005,13L6.588,9.414M9.413,6.586L12.995,3M2.152,13.854C1.957,13.658 1.957,13.342 2.152,13.147C2.348,12.951 2.664,12.951 2.859,13.147C3.054,13.342 3.054,13.658 2.859,13.854C2.664,14.049 2.347,14.049 2.152,13.854M13.141,2.854C12.946,2.658 12.946,2.342 13.141,2.146C13.336,1.951 13.653,1.951 13.848,2.146C14.043,2.342 14.043,2.658 13.848,2.854C13.653,3.049 13.336,3.049 13.141,2.854M6.587,9.414C5.807,8.633 5.807,7.367 6.587,6.586C7.368,5.805 8.633,5.805 9.413,6.586C9.413,6.586 9.413,6.586 9.413,6.586C10.193,7.367 10.193,8.633 9.413,9.414C8.633,10.195 7.368,10.195 6.588,9.414C6.588,9.414 6.587,9.414 6.587,9.414"/> +</svg> diff --git a/frontend/resources/images/icons/handlers-independent.svg b/frontend/resources/images/icons/handlers-independent.svg new file mode 100644 index 0000000000..36e1c06917 --- /dev/null +++ b/frontend/resources/images/icons/handlers-independent.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round"> + <path d="M4.573,14L4.629,13.503L4.853,10.488M6.488,7.086L12.073,2.5M4.22,14.354C4.415,14.549 4.732,14.549 4.927,14.354C5.122,14.158 5.122,13.842 4.927,13.647C4.843,13.563 4.738,13.515 4.629,13.503C4.483,13.487 4.332,13.535 4.22,13.647C4.025,13.842 4.025,14.158 4.22,14.354M12.219,2.353C12.415,2.549 12.731,2.549 12.927,2.353C13.122,2.158 13.122,1.842 12.927,1.646C12.731,1.451 12.415,1.451 12.219,1.646C12.024,1.842 12.024,2.158 12.219,2.353M3.659,9.914C3.993,10.248 4.417,10.44 4.853,10.488C5.435,10.552 6.041,10.361 6.488,9.914C7.268,9.133 7.268,7.867 6.488,7.086C6.488,7.086 6.488,7.086 6.488,7.086C5.707,6.305 4.44,6.305 3.659,7.086C2.878,7.867 2.878,9.133 3.659,9.914"/> +</svg> diff --git a/frontend/resources/images/icons/handlers-mirror.svg b/frontend/resources/images/icons/handlers-mirror.svg new file mode 100644 index 0000000000..78f71c740e --- /dev/null +++ b/frontend/resources/images/icons/handlers-mirror.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round"> + <path d="M3.005,13L4.589,11.414M7.415,8.586L12.995,3M2.152,13.854C1.957,13.658 1.957,13.342 2.152,13.147C2.347,12.951 2.664,12.951 2.859,13.147C3.054,13.342 3.054,13.658 2.859,13.854C2.664,14.049 2.347,14.049 2.152,13.854M13.141,2.854C12.946,2.658 12.946,2.342 13.141,2.146C13.336,1.951 13.653,1.951 13.848,2.146C14.043,2.342 14.043,2.658 13.848,2.854C13.653,3.049 13.336,3.049 13.141,2.854M4.589,11.414C3.809,10.633 3.809,9.367 4.589,8.586C5.37,7.805 6.635,7.805 7.415,8.586C7.415,8.586 7.415,8.586 7.415,8.586C8.195,9.367 8.195,10.633 7.415,11.414C6.635,12.195 5.37,12.195 4.589,11.414C4.589,11.414 4.589,11.414 4.589,11.414"/> +</svg> diff --git a/frontend/resources/images/icons/snap.svg b/frontend/resources/images/icons/snap.svg new file mode 100644 index 0000000000..0acdce1f20 --- /dev/null +++ b/frontend/resources/images/icons/snap.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round"> + <path d="M7.893,10.058L10.493,12.658L10.493,12.659M1.62,7.229L5.758,3.091C7.035,1.813 8.897,1.314 10.642,1.782C12.387,2.249 13.75,3.612 14.218,5.357C14.686,7.102 14.187,8.963 12.91,10.241L10.493,12.658L8.772,14.38C8.466,14.686 7.97,14.686 7.664,14.38L6.171,12.887C5.865,12.581 5.865,12.086 6.171,11.78L7.893,10.058L10.091,7.86C10.629,7.322 10.629,6.449 10.091,5.91C9.552,5.372 8.679,5.372 8.14,5.91L5.942,8.108L4.221,9.829C3.915,10.135 3.419,10.135 3.113,9.829L1.62,8.337C1.314,8.031 1.314,7.535 1.62,7.229M5.942,8.108L5.942,8.108L3.342,5.508"/> +</svg> diff --git a/frontend/resources/styles/common/base.scss b/frontend/resources/styles/common/base.scss index 41d79a0bf4..588d484826 100644 --- a/frontend/resources/styles/common/base.scss +++ b/frontend/resources/styles/common/base.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL // TODO: Legacy sass vars. We should use DS tokens. $color-gray-50: #303236; @@ -41,6 +41,11 @@ body { scrollbar-width: thin; } +::selection { + background: var(--color-accent-background-select); + color: var(--color-static-white); +} + img { height: auto; width: 100%; diff --git a/frontend/resources/styles/common/dependencies/fonts.scss b/frontend/resources/styles/common/dependencies/fonts.scss index dd95f85bff..35fc55a872 100644 --- a/frontend/resources/styles/common/dependencies/fonts.scss +++ b/frontend/resources/styles/common/dependencies/fonts.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:string"; diff --git a/frontend/resources/styles/common/dependencies/highlight.scss b/frontend/resources/styles/common/dependencies/highlight.scss index f457cd247a..b09dd0d4cf 100644 --- a/frontend/resources/styles/common/dependencies/highlight.scss +++ b/frontend/resources/styles/common/dependencies/highlight.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:meta"; diff --git a/frontend/resources/styles/common/dependencies/storybook.scss b/frontend/resources/styles/common/dependencies/storybook.scss index 951409ac93..07cb2a472c 100644 --- a/frontend/resources/styles/common/dependencies/storybook.scss +++ b/frontend/resources/styles/common/dependencies/storybook.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .sb-show-main.sb-main-fullscreen, .sb-show-main.sb-main-padded { diff --git a/frontend/resources/styles/common/refactor/animations.scss b/frontend/resources/styles/common/refactor/animations.scss index acfcef89d3..7816078825 100644 --- a/frontend/resources/styles/common/refactor/animations.scss +++ b/frontend/resources/styles/common/refactor/animations.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @mixin animation($delay, $duration, $animation) { animation-delay: $delay; diff --git a/frontend/resources/styles/common/refactor/basic-rules.scss b/frontend/resources/styles/common/refactor/basic-rules.scss index 35d74a25e8..af5717743d 100644 --- a/frontend/resources/styles/common/refactor/basic-rules.scss +++ b/frontend/resources/styles/common/refactor/basic-rules.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "./spacing.scss" as *; @use "./borders.scss" as *; diff --git a/frontend/resources/styles/common/refactor/borders.scss b/frontend/resources/styles/common/refactor/borders.scss index ab55b9445e..9a70d87d22 100644 --- a/frontend/resources/styles/common/refactor/borders.scss +++ b/frontend/resources/styles/common/refactor/borders.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL // Border radius $br-0: 0; diff --git a/frontend/resources/styles/common/refactor/color-defs.scss b/frontend/resources/styles/common/refactor/color-defs.scss index fdaf3bf2fb..c78f5f0733 100644 --- a/frontend/resources/styles/common/refactor/color-defs.scss +++ b/frontend/resources/styles/common/refactor/color-defs.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:color"; @@ -41,6 +41,7 @@ // APP COLORS --app-white: #fff; // Used in several places --app-black: #000; // Used on interactions, measurements and editor files + --app-pink: #f49ef7; // Used in path selection // SOCIAL LOGIN BUTTONS --google-login-background: #4285f4; diff --git a/frontend/resources/styles/common/refactor/common-dashboard.scss b/frontend/resources/styles/common/refactor/common-dashboard.scss index 4952ef4b10..ba38cd0b78 100644 --- a/frontend/resources/styles/common/refactor/common-dashboard.scss +++ b/frontend/resources/styles/common/refactor/common-dashboard.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor" as *; diff --git a/frontend/resources/styles/common/refactor/common-refactor.scss b/frontend/resources/styles/common/refactor/common-refactor.scss index 9aa9de0be6..dbf8b0faf2 100644 --- a/frontend/resources/styles/common/refactor/common-refactor.scss +++ b/frontend/resources/styles/common/refactor/common-refactor.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL // ################################################# // MAIN STYLES diff --git a/frontend/resources/styles/common/refactor/design-tokens.scss b/frontend/resources/styles/common/refactor/design-tokens.scss index d3f1df4034..f39dad7bfb 100644 --- a/frontend/resources/styles/common/refactor/design-tokens.scss +++ b/frontend/resources/styles/common/refactor/design-tokens.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:color"; .light, diff --git a/frontend/resources/styles/common/refactor/focus.scss b/frontend/resources/styles/common/refactor/focus.scss index 970efaee2a..7bfb05d6c1 100644 --- a/frontend/resources/styles/common/refactor/focus.scss +++ b/frontend/resources/styles/common/refactor/focus.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "./spacing.scss" as *; diff --git a/frontend/resources/styles/common/refactor/fonts.scss b/frontend/resources/styles/common/refactor/fonts.scss index 5740b02538..b100b70f84 100644 --- a/frontend/resources/styles/common/refactor/fonts.scss +++ b/frontend/resources/styles/common/refactor/fonts.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:math"; diff --git a/frontend/resources/styles/common/refactor/mixins.scss b/frontend/resources/styles/common/refactor/mixins.scss index 52de45d319..b5b4770f7a 100644 --- a/frontend/resources/styles/common/refactor/mixins.scss +++ b/frontend/resources/styles/common/refactor/mixins.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "./fonts.scss" as *; @use "./spacing.scss" as *; diff --git a/frontend/resources/styles/common/refactor/opacity.scss b/frontend/resources/styles/common/refactor/opacity.scss index 3122c338c2..944e52354c 100644 --- a/frontend/resources/styles/common/refactor/opacity.scss +++ b/frontend/resources/styles/common/refactor/opacity.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL // Opacity values $op-0: 0; diff --git a/frontend/resources/styles/common/refactor/shadows.scss b/frontend/resources/styles/common/refactor/shadows.scss index a332677a00..6a2e081dea 100644 --- a/frontend/resources/styles/common/refactor/shadows.scss +++ b/frontend/resources/styles/common/refactor/shadows.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "./spacing.scss" as *; diff --git a/frontend/resources/styles/common/refactor/spacing.scss b/frontend/resources/styles/common/refactor/spacing.scss index 3158dd2b79..5bafe8512e 100644 --- a/frontend/resources/styles/common/refactor/spacing.scss +++ b/frontend/resources/styles/common/refactor/spacing.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:math"; diff --git a/frontend/resources/styles/common/refactor/themes.scss b/frontend/resources/styles/common/refactor/themes.scss index 155bfe14cf..24f15d6125 100644 --- a/frontend/resources/styles/common/refactor/themes.scss +++ b/frontend/resources/styles/common/refactor/themes.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @forward "./themes/default-theme"; @forward "./themes/light-theme"; diff --git a/frontend/resources/styles/common/refactor/themes/default-theme.scss b/frontend/resources/styles/common/refactor/themes/default-theme.scss index d40242d626..6382f8003f 100644 --- a/frontend/resources/styles/common/refactor/themes/default-theme.scss +++ b/frontend/resources/styles/common/refactor/themes/default-theme.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:meta"; diff --git a/frontend/resources/styles/common/refactor/themes/light-theme.scss b/frontend/resources/styles/common/refactor/themes/light-theme.scss index c0faf81096..249437360b 100644 --- a/frontend/resources/styles/common/refactor/themes/light-theme.scss +++ b/frontend/resources/styles/common/refactor/themes/light-theme.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:meta"; diff --git a/frontend/resources/styles/common/refactor/z-index.scss b/frontend/resources/styles/common/refactor/z-index.scss index efbb31f8bc..52f0759bed 100644 --- a/frontend/resources/styles/common/refactor/z-index.scss +++ b/frontend/resources/styles/common/refactor/z-index.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL $z-index-1: 1; // floating elements $z-index-2: 2; // sidebars diff --git a/frontend/resources/styles/debug.scss b/frontend/resources/styles/debug.scss index 65910ec5db..fc08132a10 100644 --- a/frontend/resources/styles/debug.scss +++ b/frontend/resources/styles/debug.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL // NOTE: This CSS only gets included when the NODE_ENV env var // is *not* set to `production`. diff --git a/frontend/resources/styles/main-default.scss b/frontend/resources/styles/main-default.scss index dbec87e820..e1735cbd8b 100644 --- a/frontend/resources/styles/main-default.scss +++ b/frontend/resources/styles/main-default.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL // ################################################# // MAIN STYLES diff --git a/frontend/resources/templates/index.mustache b/frontend/resources/templates/index.mustache index 453197068e..590408748d 100644 --- a/frontend/resources/templates/index.mustache +++ b/frontend/resources/templates/index.mustache @@ -23,7 +23,12 @@ <link href="css/debug.css?version={{& version_tag}}" rel="stylesheet" type="text/css" /> {{/isDebug}} + {{#isDebug}} + <link rel="icon" href="images/favicon-local.png?version={{& version_tag }}" /> + {{/isDebug}} + {{^isDebug}} <link rel="icon" href="images/favicon.png?version={{& version_tag }}" /> + {{/isDebug}} <script type="importmap">{{& manifest.importmap }}</script> diff --git a/frontend/scripts/build b/frontend/scripts/build index 2cc812e228..fe40d223bc 100755 --- a/frontend/scripts/build +++ b/frontend/scripts/build @@ -30,7 +30,7 @@ mkdir -p target/dist; # Build render wasm binary pushd ../render-wasm; -./build +./build frontend popd pushd ../mcp; diff --git a/frontend/scripts/build-fonts-preview.js b/frontend/scripts/build-fonts-preview.js index b1aa7c839c..d61e644ac2 100644 --- a/frontend/scripts/build-fonts-preview.js +++ b/frontend/scripts/build-fonts-preview.js @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL // Builds one SVG sprite previewing every catalog (built-in + Google) font name, // outlined in its own typeface, so the picker loads it once instead of one @@ -68,7 +68,7 @@ function slug(value) { } async function findGfontsJson() { - const dir = "resources/fonts"; + const dir = "../common/resources/fonts"; const entries = await fs.readdir(dir); const matches = entries.filter((f) => /^gfonts\..*\.json$/.test(f)).sort(); if (matches.length === 0) { diff --git a/frontend/scripts/test-e2e b/frontend/scripts/test-e2e index fca7cf941e..f4511b53e7 100755 --- a/frontend/scripts/test-e2e +++ b/frontend/scripts/test-e2e @@ -1,8 +1,9 @@ #!/usr/bin/env bash SCRIPT_DIR=$(dirname $0); +REPORTER=${PLAYWRIGHT_REPORTER:-list}; set -ex $SCRIPT_DIR/setup; -pnpm run test:e2e -x --workers=1 --reporter=list "$@"; +pnpm run test:e2e -x --workers=1 --reporter="$REPORTER" "$@"; diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index dc2c5a237a..6bdfa17381 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.config (:require @@ -258,6 +258,23 @@ [id] (dm/str (u/join public-uri "assets/by-id/" (str id)))) +;; Current share-id for asset URL building. The share-link viewer sets +;; this in `app.main.data.viewer/initialize` so every caller of +;; `resolve-file-media` (inspector, code panel, image previews, +;; code generators, etc.) automatically receives a share-id without +;; having to thread it through every call site. Workspace callers +;; leave it nil and continue to get the original URL shape. +(defonce ^:private ^{:doc "Active share-id used by `resolve-file-media`." + :dynamic true} + current-share-id + nil) + +(defn set-current-share-id! + "Set the share-id used by `resolve-file-media`. Pass `nil` to clear it + (e.g. when leaving the viewer)." + [share-id] + (set! current-share-id share-id)) + (defn resolve-file-media ([media] (resolve-file-media media false)) @@ -266,7 +283,8 @@ (dm/str (cond-> (u/join public-uri "assets/by-file-media-id/") (true? thumbnail?) (u/join (dm/str id "/thumbnail")) - (false? thumbnail?) (u/join (dm/str id))))))) + (false? thumbnail?) (u/join (dm/str id)) + (some? current-share-id) (u/join (dm/str "?share-id=" current-share-id))))))) (defn resolve-href [resource] diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 83d0eda803..b6fbfb3ca5 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main (:require diff --git a/frontend/src/app/main/broadcast.cljs b/frontend/src/app/main/broadcast.cljs index fe9f4e123c..a76272a262 100644 --- a/frontend/src/app/main/broadcast.cljs +++ b/frontend/src/app/main/broadcast.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.broadcast "BroadcastChannel API." diff --git a/frontend/src/app/main/constants.cljs b/frontend/src/app/main/constants.cljs index d473e621c6..4aeaa0461e 100644 --- a/frontend/src/app/main/constants.cljs +++ b/frontend/src/app/main/constants.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.constants) @@ -341,3 +341,9 @@ (def ^:const resize-sample-time default-sample-time) (def ^:const rotation-sample-time default-sample-time) (def ^:const move-sample-time default-sample-time) + +(def ^:const sidebar-transform-sample-time + "Time in ms for coalescing sidebar measures-panel transform commits: at + most one full commit per window during a burst, plus a trailing flush + with the exact final value." + 50) diff --git a/frontend/src/app/main/data/auth.cljs b/frontend/src/app/main/data/auth.cljs index 2339c02452..a79e372401 100644 --- a/frontend/src/app/main/data/auth.cljs +++ b/frontend/src/app/main/data/auth.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.auth "Auth related data events" diff --git a/frontend/src/app/main/data/changes.cljs b/frontend/src/app/main/data/changes.cljs index 74a2d97659..d45fb82b7a 100644 --- a/frontend/src/app/main/data/changes.cljs +++ b/frontend/src/app/main/data/changes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.changes (:require @@ -160,7 +160,7 @@ "Create a commit event instance" [{:keys [commit-id redo-changes undo-changes origin save-undo? features file-id file-revn file-vern undo-group tags stack-undo? source ignore-wasm? - selected-before translation?]}] + selected-before translation? skip-component-sync?]}] (assert (cpc/check-changes redo-changes) "expect valid vector of changes for redo-changes") @@ -188,7 +188,8 @@ :stack-undo? stack-undo? :ignore-wasm? ignore-wasm? :selected-before selected-before - :translation? translation?}] + :translation? translation? + :skip-component-sync? skip-component-sync?}] (ptk/reify ::commit cljs.core/IDeref @@ -227,7 +228,7 @@ undo-group, they will be undone or redone in a single step " [{:keys [redo-changes undo-changes save-undo? undo-group tags stack-undo? file-id - translation?] + translation? skip-component-sync?] :or {save-undo? true stack-undo? false undo-group (uuid/next) @@ -261,4 +262,5 @@ (assoc :redo-changes rchg) (assoc :selected-before selected) (assoc :translation? translation?) + (assoc :skip-component-sync? skip-component-sync?) (commit))))))))) diff --git a/frontend/src/app/main/data/comments.cljs b/frontend/src/app/main/data/comments.cljs index a69d759272..c156536de5 100644 --- a/frontend/src/app/main/data/comments.cljs +++ b/frontend/src/app/main/data/comments.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.comments (:require diff --git a/frontend/src/app/main/data/common.cljs b/frontend/src/app/main/data/common.cljs index f74874d297..fca2d0f567 100644 --- a/frontend/src/app/main/data/common.cljs +++ b/frontend/src/app/main/data/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.common "A general purpose events." diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index 95b9f06039..4d9619d630 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.dashboard (:require diff --git a/frontend/src/app/main/data/dashboard/shortcuts.cljs b/frontend/src/app/main/data/dashboard/shortcuts.cljs index 2e737a660a..061e53d30f 100644 --- a/frontend/src/app/main/data/dashboard/shortcuts.cljs +++ b/frontend/src/app/main/data/dashboard/shortcuts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.dashboard.shortcuts (:require diff --git a/frontend/src/app/main/data/dashboard/shortcuts/customize.cljs b/frontend/src/app/main/data/dashboard/shortcuts/customize.cljs index a9178efb1a..8828037028 100644 --- a/frontend/src/app/main/data/dashboard/shortcuts/customize.cljs +++ b/frontend/src/app/main/data/dashboard/shortcuts/customize.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.dashboard.shortcuts.customize (:require diff --git a/frontend/src/app/main/data/event.cljs b/frontend/src/app/main/data/event.cljs index 676937fd47..0c507b3f0c 100644 --- a/frontend/src/app/main/data/event.cljs +++ b/frontend/src/app/main/data/event.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.event (:require diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index b1ced71ad6..f351746b49 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.exports.assets (:require @@ -142,35 +142,57 @@ :name page-name})))))) (defn- initialize-export-status - [exports cmd resource] + "`job` is only present on the job API path; without it the widget counts the + exports the client submitted, exactly as it always has." + [exports cmd resource {:keys [job-id total status backend] :as job}] (ptk/reify ::initialize-export-status ptk/UpdateEvent (update [_ state] - (assoc state :export {:in-progress true - :resource-id (:id resource) - :healthy? true - :error false - :progress 0 - :widget-visible true - :detail-visible true - :exports exports - :last-update (ct/now) - :cmd cmd})))) + (assoc state :export (cond-> {:in-progress true + :resource-id (:id resource) + :healthy? true + :error false + :progress 0 + :widget-visible true + :detail-visible true + :exports exports + :last-update (ct/now) + :cmd cmd} + (some? job) + (assoc :job-id job-id + :total total + :status status + :backend backend)))))) (defn- update-export-status - [{:keys [done status resource-uri filename mtype] :as data}] + [{:keys [done total status resource-uri filename mtype] :as data}] (ptk/reify ::update-export-status ptk/UpdateEvent (update [_ state] (let [time-diff (ct/diff-ms (get-in state [:export :last-update]) (ct/now)) - healthy? (< time-diff 6000)] + healthy? (< time-diff 6000) + ;; The legacy path has no server-side figures to track; it keeps + ;; reporting progress over the client's own list. + job? (some? (get-in state [:export :job-id]))] (cond-> state + job? + (update :export assoc :status status) + + (and job? (some? total)) + (update :export assoc :total total) + (= status "running") (update :export assoc :progress done :last-update (ct/now) :healthy? healthy?) (= status "error") (update :export assoc :in-progress false :error (:cause data) :last-update (ct/now) :healthy? healthy?) + (= status "cancelling") + (update :export assoc :last-update (ct/now) :healthy? healthy?) + + (= status "cancelled") + (update :export assoc :in-progress false :last-update (ct/now) :healthy? healthy?) + (= status "ended") (update :export assoc :in-progress false :last-update (ct/now) :healthy? healthy?)))) @@ -179,13 +201,60 @@ (when (= status "ended") (dom/trigger-download-uri filename mtype resource-uri))))) -;; TODO: Remove once we support WASM SVG export -(def ^:private wasm-export-types #{:jpeg :webp :png :pdf}) +;; The exporter is at capacity. Not a crash: the widget says so and the user +;; retries, instead of the generic error dialog. +(def ^:private saturation-codes #{:queue-full}) + +(defn- export-failed + "Reports a failure that happened before the export ever started, so the widget + settles instead of waiting for progress that will never arrive." + [exports cmd cause] + (ptk/reify ::export-failed + ptk/UpdateEvent + (update [_ state] + (assoc state :export {:in-progress false + :widget-visible true + :detail-visible true + :healthy? true + :progress 0 + :total (count exports) + :exports exports + :cmd cmd + :error (or (ex-message cause) true) + :error-code (:code (ex-data cause)) + :last-update (ct/now)})))) + +(defn cancel-export + "Stops the running export. Only reachable on the job API path, where the + exporter can actually abort the work. + + The widget settles from here rather than from the job's `cancelled` message: + the outcome is known once the request returns, and waiting on a round trip + through redis and the websocket would leave it stuck whenever that message is + missed." + [] + (ptk/reify ::cancel-export + ptk/WatchEvent + (watch [_ state _] + (when-let [job-id (get-in state [:export :job-id])] + (let [resource-id (get-in state [:export :resource-id]) + settle (rx/concat + (rx/of (update-export-status {:status "cancelled"})) + (->> (rx/of (clear-export-state resource-id)) + (rx/delay default-timeout)))] + (rx/concat + ;; Stopping is not instantaneous: the request has to reach the + ;; exporter and the work has to unwind. + (rx/of (update-export-status {:status "cancelling"})) + (->> (rp/cmd! :cancel-export-job {:job-id job-id}) + (rx/mapcat (fn [_] settle)) + ;; Already finished, or the exporter is gone; either way + ;; there is nothing left to stop. + (rx/catch (fn [_] settle))))))))) + +(def ^:private wasm-export-types #{:jpeg :webp :png :pdf :svg}) (defn- wasm-export-enabled? - "WASM export is available: the flag is set AND render-wasm is active for the - current file. When render-wasm is inactive its shape tree isn't loaded, so a - client-side WASM render would crash." [state] (and (contains? cf/flags :wasm-export) (features/active-feature? state "render-wasm/v1"))) @@ -203,6 +272,7 @@ (effect [_ _ _] (case (:type export) :pdf (wasm.exports/export-pdf export) + :svg (wasm.exports/export-svg export) (wasm.exports/export-image export))))) (defn request-simple-export @@ -267,7 +337,8 @@ stopper (rx/filter #(or (= "ended" (:status %)) - (= "error" (:status %))) + (= "error" (:status %)) + (= "cancelled" (:status %))) progress-stream)] (swap! st/ongoing-tasks conj :export) @@ -277,11 +348,30 @@ (rx/of ::dwp/force-persist) ;; Launch the exportation process and stores the resource id - ;; locally. - (->> (rp/cmd! :export params) - (rx/map (fn [{:keys [id] :as resource}] - (vreset! resource-id id) - (initialize-export-status exports cmd resource)))) + ;; locally. With wasm export active the job API is used instead: it + ;; answers with the exporter's own object count and gives a handle + ;; to cancel. + (->> (if (wasm-export-enabled? state) + (->> (rp/cmd! :create-export-job params) + (rx/map (fn [{job-id :id :keys [total] :as job}] + (vreset! resource-id (:resource-id job)) + (initialize-export-status exports cmd + {:id (:resource-id job)} + {:job-id job-id + :total total + :status (:state job) + :backend (:backend job)})))) + (->> (rp/cmd! :export params) + (rx/map (fn [{:keys [id] :as resource}] + (vreset! resource-id id) + (initialize-export-status exports cmd resource nil))))) + (rx/catch (fn [cause] + ;; Saturation is an answer, not a fault. + (if (contains? saturation-codes (:code (ex-data cause))) + (rx/of (export-failed exports cmd cause)) + (rx/concat + (rx/of (export-failed exports cmd cause)) + (rx/throw cause)))))) ;; We proceed to update the export state with incoming ;; progress updates. We delay the stopper for give some time @@ -298,7 +388,8 @@ ;; for ensure that after some security time, the stream is ;; completely closed. (->> progress-stream - (rx/filter #(= "ended" (:status %))) + (rx/filter #(or (= "ended" (:status %)) + (= "cancelled" (:status %)))) (rx/take 1) (rx/delay default-timeout) (rx/map #(clear-export-state @resource-id)) @@ -317,7 +408,7 @@ (watch [_ state _] (let [params (select-keys (:export state) [:exports :cmd])] (when (seq params) - (rx/of (request-multiple-export params))))))) + (rx/of (request-export params))))))) (defn export-shapes-event [exports origin] diff --git a/frontend/src/app/main/data/exports/files.cljs b/frontend/src/app/main/data/exports/files.cljs index 73917747be..d50948e26e 100644 --- a/frontend/src/app/main/data/exports/files.cljs +++ b/frontend/src/app/main/data/exports/files.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.exports.files "The file exportation API and events" @@ -17,18 +17,21 @@ [potok.v2.core :as ptk])) (def valid-types - (d/ordered-set :all :merge :detach)) + (d/ordered-set :include-libraries :merge-libraries :detach-libraries :link-later)) (def valid-formats - #{:binfile-v1 :binfile-v3 :legacy-zip}) + #{:binfile-v1 :binfile-v3}) + +(def ^:private schema:export-file-param + [:map {:title "FileParam"} + [:id ::sm/uuid] + [:name :string] + [:project-id ::sm/uuid] + [:is-shared ::sm/boolean] + #_[:has-libraries ::sm/boolean]]) (def ^:private schema:export-files - [:sequential {:title "Files"} - [:map {:title "FileParam"} - [:id ::sm/uuid] - [:name :string] - [:project-id ::sm/uuid] - [:is-shared ::sm/boolean]]]) + [:sequential {:title "Files"} schema:export-file-param]) (def check-export-files (sm/check-fn schema:export-files)) @@ -57,14 +60,17 @@ :files files})))))))))) (defn export-files + "Start files exportation process" [& {:keys [type files]}] + (assert (check-export-files files) "expected a sequence of files") + (assert (valid-types type) "expected valid export type") + (->> (rx/from files) (rx/mapcat (fn [file] (->> (rp/cmd! ::sse/export-binfile {:file-id (:id file) :version 3 - :include-libraries (= type :all) - :embed-assets (= type :merge)}) + :type type}) (rx/filter sse/end-of-stream?) (rx/map sse/get-payload) (rx/map (fn [uri] diff --git a/frontend/src/app/main/data/exports/wasm.cljs b/frontend/src/app/main/data/exports/wasm.cljs index 4cbd5285f8..d2258b5e85 100644 --- a/frontend/src/app/main/data/exports/wasm.cljs +++ b/frontend/src/app/main/data/exports/wasm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.exports.wasm (:require @@ -44,3 +44,17 @@ (js/queueMicrotask #(wapi/revoke-uri url)) nil)) +(defn export-svg-uri + [{:keys [scale object-id]}] + (let [bytes (wasm.api/render-shape-svg object-id (or scale 1)) + blob (wapi/create-blob bytes "image/svg+xml")] + (wapi/create-uri blob))) + +(defn export-svg + [{:keys [suffix name] :as params}] + (let [url (export-svg-uri params) + filename (str name (or suffix "") ".svg")] + (dom/trigger-download-uri filename "image/svg+xml" url) + (js/queueMicrotask #(wapi/revoke-uri url)) + nil)) + diff --git a/frontend/src/app/main/data/fonts.cljs b/frontend/src/app/main/data/fonts.cljs index 9b0f26fdfb..2ec056487d 100644 --- a/frontend/src/app/main/data/fonts.cljs +++ b/frontend/src/app/main/data/fonts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.fonts (:require diff --git a/frontend/src/app/main/data/helpers.cljs b/frontend/src/app/main/data/helpers.cljs index 3681e821de..a0f46e30e1 100644 --- a/frontend/src/app/main/data/helpers.cljs +++ b/frontend/src/app/main/data/helpers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.helpers (:require diff --git a/frontend/src/app/main/data/media.cljs b/frontend/src/app/main/data/media.cljs index 74eec0ac56..22b2f9f13b 100644 --- a/frontend/src/app/main/data/media.cljs +++ b/frontend/src/app/main/data/media.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.media (:require diff --git a/frontend/src/app/main/data/modal.cljs b/frontend/src/app/main/data/modal.cljs index 052080f3fb..458eba37ad 100644 --- a/frontend/src/app/main/data/modal.cljs +++ b/frontend/src/app/main/data/modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.modal (:refer-clojure :exclude [update]) diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index ff0f48c3e2..4be62a2cbf 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -1,5 +1,6 @@ (ns app.main.data.nitrate (:require + [app.common.data :as d] [app.common.data.macros :as dm] [app.common.types.organization :as cto] [app.common.uri :as u] @@ -42,15 +43,25 @@ (swap! storage/storage dissoc nitrate-entry-pending-popup-key))) +(def ^:private offline-connectivity + {:licenses false}) + +(defn- air-gapped? + [] + (contains? cf/flags :air-gapped-conf)) + (defn show-nitrate-popup ([popup-type] (show-nitrate-popup popup-type {})) ([popup-type extra-props] (ptk/reify ::show-nitrate-popup ptk/WatchEvent (watch [_ _ _] - (->> (rp/cmd! ::get-nitrate-connectivity {}) - (rx/map (fn [connectivity] - (modal/show popup-type (merge (or connectivity {}) extra-props))))))))) + (if (air-gapped?) + (rx/of (modal/show popup-type (merge offline-connectivity extra-props))) + (->> (rp/cmd! ::get-nitrate-connectivity {}) + (rx/map (fn [connectivity] + (modal/show popup-type + (merge (or connectivity {}) extra-props)))))))))) (defn build-admin-console-url ([path] @@ -351,6 +362,35 @@ (rx/empty))))))))))) +(defn check-organization-sso + "Asks the backend whether the organization SSO gate can be satisfied for + `dest-url`, returning an observable of the raw `:check-nitrate-sso` + result: `:authorized` with a `:reason` of `:sso-satisfied` or + `:no-team-access`, or `:authorized false` with a `:redirect-uri` (nil + when SSO is required but the provider is unusable). Failures are not + caught, so a network blip stays a network error for the caller to + handle instead of masquerading as an answer." + [{:keys [team-id organization-id dest-url]}] + (rp/cmd! :check-nitrate-sso (d/without-nils {:team-id team-id + :organization-id organization-id + :url dest-url}))) + +(defn retry-organization-sso + "Retries the organization SSO login flow after a failed attempt, reusing + the same check-nitrate-sso RPC used elsewhere to move the user through + the organization's identity provider. Passing `team-id` enables the + backend's non-member short-circuit. Falls back to navigating straight + to `dest-url` when no fresh SSO redirect is needed or available." + [{:keys [dest-url] :as params}] + (ptk/reify ::retry-organization-sso + ptk/WatchEvent + (watch [_ _ _] + (->> (check-organization-sso params) + (rx/map (fn [{:keys [redirect-uri]}] + (rt/nav-raw :uri (or redirect-uri dest-url)))) + (rx/catch (fn [_] + (rx/of (rt/nav-raw :uri dest-url)))))))) + (defn- fetch-organizations-allowed "Returns an rx observable of an `organizations-allowed` map (organization-id -> boolean). Organizations where :add-anybody-to-team is permitted are pre-approved; @@ -385,6 +425,7 @@ is-own? (= profile-id (:owner-id organization))] (or (= perm "any") is-own?))) all-organizations) team (first (filter #(= (:id %) team-id) teams)) + current-organization (:organization team) on-confirm (fn [organization-id] (st/emit! (add-team-to-organization {:team-id team-id :organization-id organization-id}))) @@ -392,11 +433,11 @@ (fn [organizations-allowed] (let [has-filtered? (< (count organizations) (count all-organizations)) extra-props (when has-filtered? - {:info-message-key "dashboard.select-organization-modal.permission-info"})] + {:info-message-key "dashboard.select-organization-modal.permission-info-add"})] (modal/show :select-organization-modal (merge {:organizations organizations :organizations-allowed organizations-allowed - :current-organization-id (dm/get-in team [:organization :id]) + :current-organization current-organization :on-confirm on-confirm :team-id team-id :title-key "dashboard.select-organization-modal.title" @@ -479,11 +520,12 @@ :title (tr "dashboard.change-organization-modal.title")}) (modal/show :select-organization-modal (merge {:organizations selectable-organizations - :organizations-allowed organizations-allowed - :current-organization-id current-organization-id + :organizations-allowed organizations-allowed + :current-organization source-organization :on-confirm on-confirm :team-id team-id :title-key "dashboard.change-organization-modal.title" + :description-key "dashboard.change-organization-modal.description" :choose-key "dashboard.change-organization-modal.choose" :placeholder-key "dashboard.change-organization-modal.select" :accept-key "dashboard.change-organization-modal.accept" diff --git a/frontend/src/app/main/data/nitrate_audit.cljs b/frontend/src/app/main/data/nitrate_audit.cljs index 619f56d3ec..0d75d0014e 100644 --- a/frontend/src/app/main/data/nitrate_audit.cljs +++ b/frontend/src/app/main/data/nitrate_audit.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.nitrate-audit (:require diff --git a/frontend/src/app/main/data/notifications.cljs b/frontend/src/app/main/data/notifications.cljs index 7e4f46f91d..2a9e823612 100644 --- a/frontend/src/app/main/data/notifications.cljs +++ b/frontend/src/app/main/data/notifications.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.notifications (:require diff --git a/frontend/src/app/main/data/persistence.cljs b/frontend/src/app/main/data/persistence.cljs index 03fbdb319a..55eac8177d 100644 --- a/frontend/src/app/main/data/persistence.cljs +++ b/frontend/src/app/main/data/persistence.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.persistence (:require diff --git a/frontend/src/app/main/data/plugins.cljs b/frontend/src/app/main/data/plugins.cljs index 6635fc070b..33d898cf69 100644 --- a/frontend/src/app/main/data/plugins.cljs +++ b/frontend/src/app/main/data/plugins.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.plugins (:require @@ -39,6 +39,7 @@ :uri plugin-url :omit-default-headers true :response-type :json}) + (rx/timeout 15000) (rx/map :body) (rx/map #(preg/parse-manifest plugin-url %)))) @@ -114,7 +115,7 @@ [{:keys [url] :as manifest} user-can-edit?] (if url ;; If the saved manifest has a URL we fetch the manifest to check - ;; for updates + ;; for updates and validate integrity (->> (fetch-manifest url) (rx/subs! (fn [new-manifest] @@ -126,6 +127,8 @@ (cond (and is-edition-plugin? (not user-can-edit?)) (st/emit! (ntf/warn (tr "workspace.plugins.error.need-editor"))) + + ;; Permissions changed - show permissions dialog (not= (:permissions new-manifest) (:permissions manifest)) (modal/show! :plugin-permissions-update @@ -135,15 +138,25 @@ (preg/install-plugin! new-manifest) (load-plugin! new-manifest))}) + ;; Manifest changed (code, name, etc.) - require re-confirmation + ;; This prevents execution of tampered/injected plugins (not= new-manifest manifest) - (do (preg/install-plugin! new-manifest) - (load-plugin! manifest)) + (modal/show! + :plugin-permissions-update + {:plugin new-manifest + :on-accept + #(do + (preg/install-plugin! new-manifest) + (load-plugin! new-manifest))}) + + ;; Manifests match exactly - safe to load :else (load-plugin! manifest)))) - (fn [] - ;; Error fetching the manifest we'll load the plugin with the - ;; old manifest - (load-plugin! manifest)))) + (fn [_err] + ;; Error fetching the manifest - can't verify integrity + ;; Show error instead of loading potentially tampered code + (st/emit! (ntf/warn (tr "workspace.plugins.error.unreachable")))))) + ;; Bundled plugins (no URL) - trusted, load directly (load-plugin! manifest))) (defn close-plugin! diff --git a/frontend/src/app/main/data/preview.cljs b/frontend/src/app/main/data/preview.cljs index c300c0f88f..ce28ab8b31 100644 --- a/frontend/src/app/main/data/preview.cljs +++ b/frontend/src/app/main/data/preview.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.preview (:require diff --git a/frontend/src/app/main/data/profile.cljs b/frontend/src/app/main/data/profile.cljs index e37dc08e45..f0814a2098 100644 --- a/frontend/src/app/main/data/profile.cljs +++ b/frontend/src/app/main/data/profile.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.profile (:require @@ -43,28 +43,30 @@ (defn set-profile "Initialize profile state, only logged-in profile data should be passed to this event" - [{:keys [id] :as profile}] - (ptk/reify ::set-profile - IDeref - (-deref [_] profile) + [profile] + (let [profile (update profile :theme not-empty) + id (:id profile)] + (ptk/reify ::set-profile + IDeref + (-deref [_] profile) - ptk/UpdateEvent - (update [_ state] - (-> state - (assoc :profile-id id) - (assoc :profile profile))) + ptk/UpdateEvent + (update [_ state] + (-> state + (assoc :profile-id id) + (assoc :profile profile))) - ptk/WatchEvent - (watch [_ state _] - (let [profile (:profile state)] - (->> (rx/from (i18n/set-locale (:lang profile))) - (rx/ignore)))) + ptk/WatchEvent + (watch [_ state _] + (let [profile (:profile state)] + (->> (rx/from (i18n/set-locale (:lang profile))) + (rx/ignore)))) - ptk/EffectEvent - (effect [_ state _] - (let [profile (:profile state)] - (swap! storage/user assoc :profile profile) - (plugins.register/init))))) + ptk/EffectEvent + (effect [_ state _] + (let [profile (:profile state)] + (swap! storage/user assoc :profile profile) + (plugins.register/init)))))) (def profile-fetched? (ptk/type? ::profile-fetched)) @@ -120,6 +122,10 @@ ;; --- Update Profile +(defn profile-update-params + [profile] + (d/without-nils (select-keys profile [:fullname :lang :theme]))) + (defn persist-profile [& {:as opts}] (ptk/reify ::persist-profile @@ -128,7 +134,7 @@ (let [on-success (:on-success opts identity) on-error (:on-error opts rx/throw) profile (:profile state) - params (select-keys profile [:fullname :lang :theme])] + params (profile-update-params profile)] (->> (rp/cmd! :update-profile params) (rx/tap on-success) (rx/map set-profile) @@ -141,7 +147,7 @@ props" [profile] - (let [profile (check-profile profile)] + (let [profile (check-profile (d/without-nils profile))] (ptk/reify ::update-profile ptk/WatchEvent (watch [_ state _] @@ -462,11 +468,14 @@ ptk/WatchEvent (watch [_ _ _] (let [{:keys [on-error on-success] - :or {on-error rx/throw + :or {on-error identity on-success identity}} (meta data)] (->> (rp/cmd! :recover-profile data) (rx/tap on-success) - (rx/catch on-error))))))) + (rx/catch (fn [err] + (on-error err) + (rx/empty))) + (rx/ignore))))))) ;; --- EVENT: fetch-team-webhooks diff --git a/frontend/src/app/main/data/project.cljs b/frontend/src/app/main/data/project.cljs index 41a1ce0c9e..1d7db839c6 100644 --- a/frontend/src/app/main/data/project.cljs +++ b/frontend/src/app/main/data/project.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.project (:require diff --git a/frontend/src/app/main/data/shortcuts.cljs b/frontend/src/app/main/data/shortcuts.cljs index 19a473ae54..6ed22e5fb1 100644 --- a/frontend/src/app/main/data/shortcuts.cljs +++ b/frontend/src/app/main/data/shortcuts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.shortcuts (:refer-clojure :exclude [meta reset!]) @@ -204,20 +204,27 @@ (defn- bind! [shortcuts] - (->> shortcuts - (remove #(:disabled (second %))) - (run! (fn [[key {:keys [command fn type overwrite]}]] - (let [callback (wrap-cb key fn) - commands (if (vector? command) - (into-array command) - #js [command])] - (if (vector? type) - (do (mousetrap/bind commands callback (nth type 0) overwrite) - (mousetrap/bind commands callback (nth type 1) overwrite)) - (let [undefined (js* "(void 0)")] - (if type - (mousetrap/bind commands callback type overwrite) - (mousetrap/bind commands callback undefined overwrite))))))))) + (let [entries (remove #(:disabled (second %)) shortcuts) + bind-fn (fn [[key {:keys [command fn type overwrite]}]] + (let [callback (wrap-cb key fn) + commands (if (vector? command) + (into-array command) + #js [command])] + (if (vector? type) + (do (mousetrap/bind commands callback (nth type 0) overwrite) + (mousetrap/bind commands callback (nth type 1) overwrite)) + (let [undefined (js* "(void 0)")] + (if type + (mousetrap/bind commands callback type overwrite) + (mousetrap/bind commands callback undefined overwrite))))))] + ;; Bind non-overwrite entries first so that entries flagged with + ;; `:overwrite` are bound last and can reliably splice out the + ;; colliding callbacks bound earlier (mousetrap's overwrite only + ;; removes callbacks that were already registered for the same + ;; combo). Map iteration order is hash-based, so we must force the + ;; order explicitly. + (run! bind-fn (remove (comp :overwrite second) entries)) + (run! bind-fn (filter (comp :overwrite second) entries)))) (defn- reset! ([] diff --git a/frontend/src/app/main/data/style_dictionary.cljs b/frontend/src/app/main/data/style_dictionary.cljs index 446e130bdb..9b37274b5f 100644 --- a/frontend/src/app/main/data/style_dictionary.cljs +++ b/frontend/src/app/main/data/style_dictionary.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.style-dictionary (:require @@ -557,7 +557,8 @@ (.. sd-token -original -name)) (defn sd-token-uuid [^js sd-token] - (uuid (.-uuid (.. sd-token -original -id)))) + (when-let [id (.. sd-token -original -id)] + (uuid (.-uuid id)))) (defn- merge-name-collisions "Re-attach tokens that `ctob/tokens-tree` / `backtrace-tokens-tree` @@ -583,11 +584,38 @@ (into {}))] (merge resolved dropped))) +(defn- valid-token-value? + [[_ token]] + (some? (:value token))) + +(def ^:private xform-invalid-value-tokens + (comp + (remove valid-token-value?) + (map (fn [[k token]] + [k (assoc token :errors [(wte/get-error-code :error.token/empty-input)])])))) + +(defn- merge-invalid-value-tokens + "Tokens with a `nil` value (e.g. a composite typography token saved with + no fields filled in) must never reach StyleDictionary: some of its + preprocessors (`@tokens-studio/sd-transforms`'s font-styles preprocessor, + in particular) assume a typography token's value is never null and throw + an uncaught exception when it is, taking down token resolution for the + whole file. + + `tokens` is the full, unfiltered token map; `resolved` only contains the + valid subset that was actually sent to StyleDictionary. Tag the invalid + ones with the same \"empty value\" error the token forms already use + instead of ever letting them reach the resolver." + [tokens resolved] + (into resolved xform-invalid-value-tokens tokens)) + (defn resolve-tokens [tokens] - (let [tokens-tree (ctob/tokens-tree tokens)] - (->> (resolve-tokens-tree tokens-tree #(get tokens (sd-token-name %))) - (rx/map #(merge-name-collisions tokens %))))) + (let [valid-tokens (into {} (filter valid-token-value?) tokens) + tokens-tree (ctob/tokens-tree valid-tokens)] + (->> (resolve-tokens-tree tokens-tree #(get valid-tokens (sd-token-name %))) + (rx/map #(merge-name-collisions valid-tokens %)) + (rx/map #(merge-invalid-value-tokens tokens %))))) (defn resolve-tokens-interactive "Interactive check of resolving tokens. @@ -609,15 +637,18 @@ computation we can restore any token, even clashing ones with the same :name path by just looking up that :id in the ids map." [tokens] - (let [{:keys [tokens-tree ids]} (ctob/backtrace-tokens-tree tokens)] - (->> (resolve-tokens-tree tokens-tree #(get ids (sd-token-uuid %))) - (rx/map #(merge-name-collisions tokens %))))) + (let [valid-tokens (into {} (filter valid-token-value?) tokens) + {:keys [tokens-tree ids]} (ctob/backtrace-tokens-tree valid-tokens)] + (->> (resolve-tokens-tree tokens-tree #(get ids (sd-token-uuid %))) + (rx/map #(merge-name-collisions valid-tokens %)) + (rx/map #(merge-invalid-value-tokens tokens %))))) (defn resolve-tokens-with-verbose-errors [tokens] - (resolve-tokens-tree - (ctob/tokens-tree tokens) - #(get tokens (sd-token-name %)) - (StyleDictionary. (assoc default-config :log {:verbosity "verbose"})))) + (let [valid-tokens (into {} (filter valid-token-value?) tokens)] + (resolve-tokens-tree + (ctob/tokens-tree valid-tokens) + #(get valid-tokens (sd-token-name %)) + (StyleDictionary. (assoc default-config :log {:verbosity "verbose"}))))) ;; === Hooks diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index 74b9a97bc9..bff83ff03a 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.team (:require @@ -61,13 +61,18 @@ ;; Delete old teams from state state (update state :teams #(select-keys % team-ids))] (reduce (fn [state {:keys [id organization-id] :as team}] - (let [team-updated (cond-> (merge (dm/get-in state [:teams id]) team) - (not organization-id) (dissoc :organization-id - :organization-name - :organization-slug - :organization-owner-id - :organization-avatar-bg-url - :organization-permissions))] + (let [team-merged (merge (dm/get-in state [:teams id]) team) + has-org? (or (some? organization-id) (some? (:organization team))) + team-updated (if has-org? + team-merged + (dissoc team-merged + :organization + :organization-id + :organization-name + :organization-slug + :organization-owner-id + :organization-avatar-bg-url + :organization-permissions))] (update state :teams assoc id team-updated))) state teams))))) @@ -609,50 +614,56 @@ (defn check-and-submit-invite-members "Fetches fresh team data from the server to ensure up-to-date organization - permissions, then submits member invitations or shows a restriction modal." + permissions, then submits member invitations or shows a permission error." [{:keys [team-id] :as params} origin do-invite-members] (ptk/reify ::check-and-submit-invite-members ptk/WatchEvent - (watch [_ _ _] - (if (contains? cf/flags :admin-console) - (with-refreshed-team team-id - (fn [team] - (if (not (cto/allowed? :add-anybody-to-team - {:organization-perms (:organization team)})) - (->> (rp/cmd! :check-organization-members {:organization-id (get-in team [:organization :id]) - :emails (vec (:emails params))}) - (rx/mapcat - (fn [result] - (let [blocked (into [] (comp (filter (fn [[_ v]] (not v))) - (map first)) - result)] - (cond - (empty? blocked) - (do (do-invite-members params origin) (rx/empty)) + (watch [_ state _] + (let [profile-id (dm/get-in state [:profile :id])] + (if (contains? cf/flags :admin-console) + (with-refreshed-team team-id + (fn [team] + (if (not (cto/can-send-invitations? + {:organization (:organization team) + :profile-id profile-id + :team-permissions (:permissions team)})) + (rx/of (modal/show :no-permission-modal {:type :invite-members})) + (if (not (cto/allowed? :add-anybody-to-team + {:organization-perms (:organization team)})) + (->> (rp/cmd! :check-organization-members {:organization-id (get-in team [:organization :id]) + :emails (vec (:emails params))}) + (rx/mapcat + (fn [result] + (let [blocked (into [] (comp (filter (fn [[_ v]] (not v))) + (map first)) + result)] + (cond + (empty? blocked) + (do (do-invite-members params origin) (rx/empty)) - (= (count blocked) (count result)) - (rx/of - (modal/show - {:type :alert - :title (tr "modals.invite-restricted-members.all-blocked-title") - :message (tr "modals.invite-restricted-members.all-blocked") - :accept-label (tr "labels.accept") - :accept-style :primary})) + (= (count blocked) (count result)) + (rx/of + (modal/show + {:type :alert + :title (tr "modals.invite-restricted-members.all-blocked-title") + :message (tr "modals.invite-restricted-members.all-blocked") + :accept-label (tr "labels.accept") + :accept-style :primary})) - :else - (rx/of - (modal/show - {:type :invite-restricted-members - :blocked-emails blocked - :on-accept (fn [] - (let [valid-emails (into #{} (filter (fn [e] (get result e))) - (:emails params)) - params' (assoc params :emails valid-emails)] - (do-invite-members params' origin)))}))))))) - (do (do-invite-members params origin) - (rx/empty))))) - (do (do-invite-members params origin) - (rx/empty)))))) + :else + (rx/of + (modal/show + {:type :invite-restricted-members + :blocked-emails blocked + :on-accept (fn [] + (let [valid-emails (into #{} (filter (fn [e] (get result e))) + (:emails params)) + params' (assoc params :emails valid-emails)] + (do-invite-members params' origin)))}))))))) + (do (do-invite-members params origin) + (rx/empty)))))) + (do (do-invite-members params origin) + (rx/empty))))))) (defn copy-invitation-link [{:keys [email team-id] :as params}] diff --git a/frontend/src/app/main/data/tinycolor.cljs b/frontend/src/app/main/data/tinycolor.cljs index 5b3b36c2f0..a0d32374c7 100644 --- a/frontend/src/app/main/data/tinycolor.cljs +++ b/frontend/src/app/main/data/tinycolor.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.tinycolor "Bindings for tinycolor2 which supports a wide range of css compatible colors. diff --git a/frontend/src/app/main/data/uploads.cljs b/frontend/src/app/main/data/uploads.cljs index 7d3ee4f071..5ae47c477a 100644 --- a/frontend/src/app/main/data/uploads.cljs +++ b/frontend/src/app/main/data/uploads.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.uploads "Generic chunked-upload helpers. diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs index 1d9c49f9d3..4ad40beade 100644 --- a/frontend/src/app/main/data/viewer.cljs +++ b/frontend/src/app/main/data/viewer.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.viewer (:require @@ -95,14 +95,21 @@ ;; browser just focus the opened tab instead of creating new ;; tab. (let [name (str "viewer-" file-id)] - (unchecked-set ug/global "name" name))))) + (unchecked-set ug/global "name" name)) + ;; Make every `cf/resolve-file-media` call (inspector, code panel, + ;; image previews, ...) share-link aware for the lifetime of this + ;; viewer. Cleared by `finalize` below. + (cf/set-current-share-id! share-id)))) (defn finalize [_] (ptk/reify ::finalize ptk/UpdateEvent (update [_ state] - (dissoc state :viewer)))) + (dissoc state :viewer)) + ptk/EffectEvent + (effect [_ _ _] + (cf/set-current-share-id! nil)))) ;; --- Data Fetching @@ -319,11 +326,12 @@ (filter #(= page-id (:page-id %))) (d/index-by :id) (assoc state :comment-threads))) - (on-error [{:keys [type] :as err}] - (if (or (= :authentication type) - (= :not-found type)) - (rx/empty) - (rx/throw err)))] + (on-error [cause] + (let [{:keys [type]} (ex-data cause)] + (if (or (= :authentication type) + (= :not-found type)) + (rx/empty) + (rx/throw cause))))] (ptk/reify ::fetch-comment-threads ptk/WatchEvent diff --git a/frontend/src/app/main/data/viewer/shortcuts.cljs b/frontend/src/app/main/data/viewer/shortcuts.cljs index f16dcb6d3a..e50142e424 100644 --- a/frontend/src/app/main/data/viewer/shortcuts.cljs +++ b/frontend/src/app/main/data/viewer/shortcuts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.viewer.shortcuts (:require diff --git a/frontend/src/app/main/data/websocket.cljs b/frontend/src/app/main/data/websocket.cljs index df55f55dce..9b28df7448 100644 --- a/frontend/src/app/main/data/websocket.cljs +++ b/frontend/src/app/main/data/websocket.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.websocket (:require diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index cedfad1d96..245df6008f 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace (:require @@ -17,11 +17,14 @@ [app.common.geom.proportions :as gpp] [app.common.geom.shapes :as gsh] [app.common.logging :as log] + [app.common.math :as mth] [app.common.path-names :as cpn] + [app.common.render-wasm.wasm :as wasm-state] [app.common.transit :as t] [app.common.types.component :as ctc] [app.common.types.components-list :as ctkl] [app.common.types.shape :as cts] + [app.common.types.tokens-lib :as ctob] [app.common.types.variant :as ctv] [app.common.uuid :as uuid] [app.config :as cf] @@ -77,7 +80,6 @@ [app.plugins.register :as preg] [app.render-wasm :as wasm] [app.render-wasm.api :as wasm.api] - [app.render-wasm.wasm :as wasm-state] [app.util.dom :as dom] [app.util.globals :as ug] [app.util.http :as http] @@ -242,7 +244,8 @@ {:redo-changes changes :undo-changes [] :save-undo? false :origin it - :tags #{:position-data}})) + :tags #{:position-data} + :skip-component-sync? true})) (rx/empty))))))) (defn- workspace-initialized @@ -266,6 +269,59 @@ (rx/map (fn [_] (mcp/init)))) (rx/empty)))))) +(defn- compute-shape-stats + "Compute shape statistics in a single pass over pages-index. + Returns {:num-shapes N :max-shapes-per-page M}" + [pages-index] + (reduce-kv + (fn [acc _page-id page] + (let [n (count (:objects page))] + (-> acc + (update :num-shapes + n) + (update :max-shapes-per-page max n)))) + {:num-shapes 0 + :max-shapes-per-page 0} + pages-index)) + +(defn compute-file-stats + "Compute file statistics. Returns a map of stats without event keys." + [state file-id] + (let [file (dsh/lookup-file state file-id) + file-data (:data file) + libraries (refs/select-libraries (:files state) file-id) + pages-index (:pages-index file-data) + {:keys [num-shapes max-shapes-per-page]} (compute-shape-stats pages-index) + n-pages (count (:pages file-data)) + n-components (reduce-kv (fn [n _ c] (if (:deleted c) n (inc n))) + 0 (:components file-data)) + n-linked-libs (dec (count libraries)) + tokens-lib (:tokens-lib file-data) + n-tokens (if (some? tokens-lib) + (count (ctob/get-all-tokens tokens-lib)) + 0)] + {:num-pages n-pages + :num-shapes num-shapes + :avg-shapes-per-page (if (pos? n-pages) + (mth/round (/ num-shapes n-pages)) + 0) + :max-shapes-per-page max-shapes-per-page + :num-components n-components + :num-linked-libraries (max 0 n-linked-libs) + :is-library (:is-shared file) + :num-tokens n-tokens})) + +(defn- emit-workspace-file-stats + [file-id team-id] + (ptk/reify ::emit-workspace-file-stats + ptk/WatchEvent + (watch [_ state _] + (let [stats (compute-file-stats state file-id)] + (rx/of (ev/event (assoc stats + ::ev/name "open-workspace-file" + ::ev/origin "workspace" + :file-id file-id + :team-id team-id))))))) + (defn- bundle-fetched [{:keys [file file-id thumbnails] :as bundle}] (ptk/reify ::bundle-fetched @@ -346,7 +402,8 @@ (assoc :recent-colors (:recent-colors storage/user)) (assoc :recent-fonts (:recent-fonts storage/user)) (assoc :current-file-id file-id) - (assoc :workspace-presence {}))) + (assoc :workspace-presence {}) + (update :workspace-global dissoc :default-font))) ptk/WatchEvent (watch [_ state stream] @@ -420,6 +477,12 @@ (rx/take 1) (rx/map dwc/set-workspace-visited)) + ;; Emit audit event with file statistics once all libraries are resolved + (->> stream + (rx/filter (ptk/type? ::all-libraries-resolved)) + (rx/take 1) + (rx/map #(emit-workspace-file-stats file-id team-id))) + (when-let [component-id (some-> rparams :component-id uuid/parse)] (->> stream (rx/filter (ptk/type? ::workspace-initialized)) @@ -449,7 +512,7 @@ (rx/filter (ptk/type? :app.render-wasm.api/stale-text-selrects)) (rx/map deref) (rx/map (fn [{:keys [ids]}] - (dwwt/resize-wasm-text-all ids)))) + (dwwt/resize-wasm-text-all ids {:skip-component-sync? true})))) (let [local-commits-s (->> stream @@ -503,7 +566,8 @@ (dch/commit-changes {:redo-changes changes :undo-changes [] :save-undo? false - :tags #{:position-data}}))))) + :tags #{:position-data} + :skip-component-sync? true}))))) (rx/take-until stoper-s))) (->> stream @@ -544,7 +608,7 @@ :workspace-tokens :workspace-undo :workspace-versions) - (update :workspace-global dissoc :read-only?) + (update :workspace-global dissoc :read-only? :default-font) (assoc-in [:workspace-global :options-mode] :design) (update :files d/update-vals #(dissoc % :data)))) @@ -1551,9 +1615,12 @@ (dm/export dwt/trigger-bounding-box-cloaking) (dm/export dwt/start-resize) (dm/export dwt/update-dimensions) +(dm/export dwt/update-dimensions-coalesced) (dm/export dwt/change-orientation) (dm/export dwt/start-rotate) +(dm/export dwt/start-move-line-point) (dm/export dwt/increase-rotation) +(dm/export dwt/increase-rotation-coalesced) (dm/export dwt/start-move-selected) (dm/export dwt/move-selected) (dm/export dwt/update-position) @@ -1634,6 +1701,7 @@ (dm/export dwgu/set-hover-guide) ;; Zoom +(dm/export dwz/center-on-shape) (dm/export dwz/reset-zoom) (dm/export dwz/zoom-to-selected-shape) (dm/export dwz/start-zooming) diff --git a/frontend/src/app/main/data/workspace/assets.cljs b/frontend/src/app/main/data/workspace/assets.cljs index dd1b3befc2..c48db42832 100644 --- a/frontend/src/app/main/data/workspace/assets.cljs +++ b/frontend/src/app/main/data/workspace/assets.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.assets "Workspace assets management events and helpers." diff --git a/frontend/src/app/main/data/workspace/bool.cljs b/frontend/src/app/main/data/workspace/bool.cljs index 066dae66c1..a961299f5d 100644 --- a/frontend/src/app/main/data/workspace/bool.cljs +++ b/frontend/src/app/main/data/workspace/bool.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.bool (:require diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index ac1fb64e9a..58d4404241 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.clipboard (:require @@ -40,6 +40,7 @@ [app.main.data.notifications :as ntf] [app.main.data.persistence :as dps] [app.main.data.workspace.media :as dwm] + [app.main.data.workspace.path.clipboard :as path-cp] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.texts :as dwtxt] @@ -292,8 +293,9 @@ (rx/mapcat (fn [pdata] (case (:type pdata) - :copied-props (rx/of (paste-transit-props pdata)) - :copied-shapes (rx/of (paste-transit-shapes pdata)) + :copied-props (rx/of (paste-transit-props pdata)) + :copied-shapes (rx/of (paste-transit-shapes pdata)) + :copied-path-content (rx/of (path-cp/paste-nodes-as-shape (:content pdata))) (rx/empty))))) :else @@ -1217,7 +1219,7 @@ (rx/mapcat (fn [blob] ;; Resolve the deferred with the fetched blob; the browser ;; will now complete the clipboard write it started earlier. - (p/resolve! deferred blob) + (p/resolve deferred blob) (rx/from write-promise))) (rx/map (fn [_] (ntf/success (tr "workspace.clipboard.image-copied")))) @@ -1225,5 +1227,5 @@ (js/console.error "clipboard error:" e) ;; Reject the deferred in case the error occurred before the ;; blob was fetched, so the pending clipboard write is cancelled. - (p/reject! deferred e) + (p/reject deferred e) (rx/of (ntf/error (tr "workspace.clipboard.image-copy-failed"))))))))))) diff --git a/frontend/src/app/main/data/workspace/collapse.cljs b/frontend/src/app/main/data/workspace/collapse.cljs index b84e71c62b..28850820ac 100644 --- a/frontend/src/app/main/data/workspace/collapse.cljs +++ b/frontend/src/app/main/data/workspace/collapse.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.collapse (:require diff --git a/frontend/src/app/main/data/workspace/colors.cljs b/frontend/src/app/main/data/workspace/colors.cljs index a844bb5a40..d4e7f587f8 100644 --- a/frontend/src/app/main/data/workspace/colors.cljs +++ b/frontend/src/app/main/data/workspace/colors.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.colors (:require diff --git a/frontend/src/app/main/data/workspace/comments.cljs b/frontend/src/app/main/data/workspace/comments.cljs index 77735a7a42..a441a01c58 100644 --- a/frontend/src/app/main/data/workspace/comments.cljs +++ b/frontend/src/app/main/data/workspace/comments.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.comments (:require diff --git a/frontend/src/app/main/data/workspace/common.cljs b/frontend/src/app/main/data/workspace/common.cljs index cd50d38b11..d682bbfe0e 100644 --- a/frontend/src/app/main/data/workspace/common.cljs +++ b/frontend/src/app/main/data/workspace/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.common (:require diff --git a/frontend/src/app/main/data/workspace/drawing.cljs b/frontend/src/app/main/data/workspace/drawing.cljs index 4812cf32a0..ba3cd380fb 100644 --- a/frontend/src/app/main/data/workspace/drawing.cljs +++ b/frontend/src/app/main/data/workspace/drawing.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.drawing "Drawing interactions." @@ -15,7 +15,6 @@ [app.main.data.workspace.drawing.common :as common] [app.main.data.workspace.drawing.curve :as curve] [app.main.data.workspace.drawing.line :as line] - [app.main.data.workspace.layout :as dwlo] [app.main.data.workspace.path :as path] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) @@ -47,11 +46,6 @@ (when (= tool :path) (rx/of (start-drawing :path))) - ;; NOTE: comments are a special case and they manage they - ;; own interrupt cycle. - (when (= tool :comments) - (rx/of (dwlo/toggle-layout-flag :display-comments :force? true))) - (when (and (not= tool :comments) (not= tool :path)) (let [stopper (rx/filter (ptk/type? ::clear-drawing) stream)] diff --git a/frontend/src/app/main/data/workspace/drawing/box.cljs b/frontend/src/app/main/data/workspace/drawing/box.cljs index 32fcc08b4e..18f510f722 100644 --- a/frontend/src/app/main/data/workspace/drawing/box.cljs +++ b/frontend/src/app/main/data/workspace/drawing/box.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.drawing.box (:require diff --git a/frontend/src/app/main/data/workspace/drawing/common.cljs b/frontend/src/app/main/data/workspace/drawing/common.cljs index 10a0164c9c..930d91a280 100644 --- a/frontend/src/app/main/data/workspace/drawing/common.cljs +++ b/frontend/src/app/main/data/workspace/drawing/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.drawing.common (:require @@ -14,6 +14,7 @@ [app.common.types.path :as path] [app.common.types.shape :as cts] [app.main.data.helpers :as dsh] + [app.main.data.workspace.path.state :as path.state] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.undo :as dwu] [app.main.worker :as mw] @@ -51,9 +52,16 @@ (ptk/reify ::clear-drawing ptk/UpdateEvent (update [_ state] - (if preserve-tool? - (update state :workspace-drawing dissoc :object :lock) - (dissoc state :workspace-drawing)))))) + (let [path-editing? (path.state/editing? state)] + (cond + path-editing? + (update state :workspace-drawing select-keys [:object]) + + preserve-tool? + (update state :workspace-drawing dissoc :object :lock) + + :else + (dissoc state :workspace-drawing))))))) (defn handle-finish-drawing [] @@ -121,6 +129,6 @@ (rx/of (dwu/commit-undo-transaction (:id shape)))) (rx/empty))))) - ;; Delay so the mouse event can read the drawing state + ;; Let the current mouse event finish before clearing drawing state. (->> (rx/of (clear-drawing {:preserve-tool? (= tool :curve)})) (rx/delay 0))))))) diff --git a/frontend/src/app/main/data/workspace/drawing/curve.cljs b/frontend/src/app/main/data/workspace/drawing/curve.cljs index 2324ae59fa..efc670b492 100644 --- a/frontend/src/app/main/data/workspace/drawing/curve.cljs +++ b/frontend/src/app/main/data/workspace/drawing/curve.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.drawing.curve (:require @@ -26,6 +26,9 @@ (def ^:const simplify-tolerance 0.3) +;; Maximum curve-fit deviation in board units. +(def ^:const smooth-tolerance 1) + (defn- setup-frame [] (ptk/reify ::setup-frame @@ -82,7 +85,7 @@ (update-in state [:workspace-drawing :object] (fn [{:keys [::points] :as shape}] (let [points (ups/simplify points simplify-tolerance) - content (path/points->content points) + content (path/smooth-points->content points smooth-tolerance) selrect (path/calc-selrect content) points (grc/rect->points selrect)] @@ -117,4 +120,3 @@ (setup-frame) (finish-drawing) (common/handle-finish-drawing))))))) - diff --git a/frontend/src/app/main/data/workspace/drawing/line.cljs b/frontend/src/app/main/data/workspace/drawing/line.cljs index 8b2995fb43..10fc76f6b7 100644 --- a/frontend/src/app/main/data/workspace/drawing/line.cljs +++ b/frontend/src/app/main/data/workspace/drawing/line.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.drawing.line "Drawing handler for the Line (L) and Arrow (Shift+L) tools. diff --git a/frontend/src/app/main/data/workspace/edition.cljs b/frontend/src/app/main/data/workspace/edition.cljs index 58815458a2..185289276e 100644 --- a/frontend/src/app/main/data/workspace/edition.cljs +++ b/frontend/src/app/main/data/workspace/edition.cljs @@ -2,12 +2,13 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.edition (:require [app.main.data.helpers :as dsh] [app.main.data.workspace.path.common :as dwpc] + [app.main.data.workspace.path.state :as path.state] [app.main.features :as features] [app.render-wasm.api :as wasm.api] [beicon.v2.core :as rx] @@ -48,26 +49,32 @@ (defn clear-edition-mode [] - (ptk/reify ::clear-edition-mode - ptk/UpdateEvent - (update [_ state] - (-> state - (update :workspace-local dissoc :edition :edit-path) - (update :workspace-drawing dissoc :object :lock) - (dissoc :workspace-grid-edition) - (dissoc :workspace-wasm-editor-styles))) + (let [path-id (volatile! nil)] + (ptk/reify ::clear-edition-mode + ptk/UpdateEvent + (update [_ state] + (let [edition-id (get-in state [:workspace-local :edition]) + path-editing? (path.state/editing? state)] + (vreset! path-id (when path-editing? edition-id)) + (-> state + (update :workspace-local dissoc :edition) + (cond-> (not path-editing?) + (update :workspace-local dissoc :edit-path) - ptk/WatchEvent - (watch [_ state _] - (let [id (get-in state [:workspace-local :edition])] - (rx/concat - (when (some? id) - (dwpc/finish-path))))) + (not path-editing?) + (update :workspace-drawing dissoc :object :lock)) + (dissoc :workspace-grid-edition) + (dissoc :workspace-wasm-editor-styles)))) - ptk/EffectEvent - (effect [_ state _] - (when (features/active-feature? state "text-editor-wasm/v1") - ;; NOTE: the WASM text editor is disposed by the v3 editor component on - ;; unmount, *after* it finalizes its content. - (wasm.api/request-render "clear-edition-mode"))))) + ptk/WatchEvent + (watch [_ _ _] + (if (some? @path-id) + (rx/of (dwpc/finish-path)) + (rx/empty))) + ptk/EffectEvent + (effect [_ state _] + (when (features/active-feature? state "text-editor-wasm/v1") + ;; NOTE: the WASM text editor is disposed by the v3 editor component on + ;; unmount, *after* it finalizes its content. + (wasm.api/request-render "clear-edition-mode")))))) diff --git a/frontend/src/app/main/data/workspace/fix_deleted_fonts.cljs b/frontend/src/app/main/data/workspace/fix_deleted_fonts.cljs index 374c163938..0f90250ca0 100644 --- a/frontend/src/app/main/data/workspace/fix_deleted_fonts.cljs +++ b/frontend/src/app/main/data/workspace/fix_deleted_fonts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.fix-deleted-fonts (:require diff --git a/frontend/src/app/main/data/workspace/grid.cljs b/frontend/src/app/main/data/workspace/grid.cljs index b2043ed71e..ed4c2d7ecc 100644 --- a/frontend/src/app/main/data/workspace/grid.cljs +++ b/frontend/src/app/main/data/workspace/grid.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.grid (:require diff --git a/frontend/src/app/main/data/workspace/grid_layout/editor.cljs b/frontend/src/app/main/data/workspace/grid_layout/editor.cljs index 8546b1178a..6187305fd0 100644 --- a/frontend/src/app/main/data/workspace/grid_layout/editor.cljs +++ b/frontend/src/app/main/data/workspace/grid_layout/editor.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.grid-layout.editor (:require diff --git a/frontend/src/app/main/data/workspace/grid_layout/shortcuts.cljs b/frontend/src/app/main/data/workspace/grid_layout/shortcuts.cljs index 326ffe93c3..fc6f2c7132 100644 --- a/frontend/src/app/main/data/workspace/grid_layout/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/grid_layout/shortcuts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.grid-layout.shortcuts (:require diff --git a/frontend/src/app/main/data/workspace/groups.cljs b/frontend/src/app/main/data/workspace/groups.cljs index 3e0f254e4a..f60ae89ba8 100644 --- a/frontend/src/app/main/data/workspace/groups.cljs +++ b/frontend/src/app/main/data/workspace/groups.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.groups (:require diff --git a/frontend/src/app/main/data/workspace/guides.cljs b/frontend/src/app/main/data/workspace/guides.cljs index 1d1c4c8059..0fd638411e 100644 --- a/frontend/src/app/main/data/workspace/guides.cljs +++ b/frontend/src/app/main/data/workspace/guides.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.guides (:require diff --git a/frontend/src/app/main/data/workspace/highlight.cljs b/frontend/src/app/main/data/workspace/highlight.cljs index e8f080127b..6e985238e2 100644 --- a/frontend/src/app/main/data/workspace/highlight.cljs +++ b/frontend/src/app/main/data/workspace/highlight.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.highlight (:require diff --git a/frontend/src/app/main/data/workspace/history.cljs b/frontend/src/app/main/data/workspace/history.cljs index 5c3ac52d60..a5059f8c64 100644 --- a/frontend/src/app/main/data/workspace/history.cljs +++ b/frontend/src/app/main/data/workspace/history.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.history (:require diff --git a/frontend/src/app/main/data/workspace/interactions.cljs b/frontend/src/app/main/data/workspace/interactions.cljs index 673697a74d..2ef326cfbc 100644 --- a/frontend/src/app/main/data/workspace/interactions.cljs +++ b/frontend/src/app/main/data/workspace/interactions.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.interactions (:require diff --git a/frontend/src/app/main/data/workspace/layers.cljs b/frontend/src/app/main/data/workspace/layers.cljs index 79fe42e501..7165851b84 100644 --- a/frontend/src/app/main/data/workspace/layers.cljs +++ b/frontend/src/app/main/data/workspace/layers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.layers "Events related with layers transformations" diff --git a/frontend/src/app/main/data/workspace/layout.cljs b/frontend/src/app/main/data/workspace/layout.cljs index fa8208c0ff..6c8aed33cc 100644 --- a/frontend/src/app/main/data/workspace/layout.cljs +++ b/frontend/src/app/main/data/workspace/layout.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.layout "Workspace layout management events and helpers." diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index d39d839ad7..60baa7e15d 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.libraries (:require @@ -40,6 +40,7 @@ [app.main.data.workspace.groups :as dwg] [app.main.data.workspace.notifications :as-alias dwn] [app.main.data.workspace.pages :as-alias dwpg] + [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.specialized-panel :as dwsp] @@ -1129,6 +1130,16 @@ (def valid-asset-types #{:colors :components :typographies}) +(defn- sync-file-pending-ids + [file-id changes] + ;; Track the file and every changed page object. + (into #{file-id} + (comp + (filter :page-id) + (keep :id) + (remove uuid/zero?)) + (:redo-changes changes))) + (defn set-updating-library [updating?] (ptk/reify ::set-updating-library @@ -1138,6 +1149,32 @@ (assoc state :updating-library true) (dissoc state :updating-library))))) +(defn- sync-file-frontend-events + [file-id changes updated-frames undo-group] + (rx/concat + (rx/of (set-updating-library false) + (ntf/hide {:tag :sync-dialog})) + (when (seq (:redo-changes changes)) + (rx/of (dch/commit-changes changes))) + (when-not (empty? updated-frames) + (let [frames-by-page (group-by :page-id updated-frames)] + (rx/merge + ;; Emit one layout/update event for each page. + (->> frames-by-page + (map (fn [[page-id frames]] + (ptk/data-event :layout/update + {:page-id page-id + :ids (map :id frames) + :undo-group undo-group}))) + (rx/from)) + (->> (rx/from updated-frames) + (rx/mapcat + (fn [shape] + (rx/of + (dwt/clear-thumbnail file-id (:page-id shape) (:id shape) "frame") + (when-not (= (:frame-id shape) uuid/zero) + (dwt/clear-thumbnail file-id (:page-id shape) (:frame-id shape) "frame"))))))))))) + (defn sync-file "Synchronize the given file from the given library. Walk through all shapes in all pages in the file that use some color, typography or @@ -1196,35 +1233,20 @@ updated-frames (->> changes :redo-changes (mapcat find-frames) - distinct)] + distinct) + + pending-ids (sync-file-pending-ids file-id changes) + + frontend-sync + (sync-file-frontend-events + file-id changes updated-frames undo-group)] (log/debug :msg "SYNC-FILE finished" :js/rchanges (log-changes (:redo-changes changes) ldata)) (rx/concat - (rx/of (set-updating-library false) - (ntf/hide {:tag :sync-dialog})) - (when (seq (:redo-changes changes)) - (rx/of (dch/commit-changes changes))) - (when-not (empty? updated-frames) - (let [frames-by-page (->> updated-frames - (group-by :page-id))] - (rx/merge - ;; Emit one layout/update event for each page - (rx/from - (map (fn [[page-id frames]] - (ptk/data-event :layout/update - {:page-id page-id - :ids (map :id frames) - :undo-group undo-group})) - frames-by-page)) - (->> (rx/from updated-frames) - (rx/mapcat - (fn [shape] - (rx/of - (dwt/clear-thumbnail file-id (:page-id shape) (:id shape) "frame") - (when-not (= (:frame-id shape) uuid/zero) - (dwt/clear-thumbnail file-id (:page-id shape) (:frame-id shape) "frame"))))))))) + ;; Keep the sync pending until its layout work starts. + (wrf/with-pending :sync-file pending-ids frontend-sync) (when (not= file-id library-id) ;; When we have just updated the library file, give some time for the @@ -1400,66 +1422,91 @@ (rx/buffer 2 1) (rx/map first)) - changes-s + ;; Barriers open before async inspection and close after detection. + pending-sync-barriers* (atom #{}) + + start-sync-barrier + (fn [{:keys [file-id save-undo?] :as event}] + (let [task (when (and save-undo? (uuid? file-id)) + (wrf/start! :sync-file [file-id]))] + (when task + (swap! pending-sync-barriers* conj task)) + [event task])) + + finish-sync-barrier! + (fn [task] + (when task + (wrf/finish! task) + (swap! pending-sync-barriers* disj task))) + + commits-s (->> stream (rx/filter dch/commit?) (rx/map deref) (rx/filter #(= :local (:source %))) + ;; Translation commits never propagate component changes. + (rx/filter (complement :translation?)) + ;; Derived / corrective commits (font-load selrect fix, + ;; position-data regen) are not user component edits. + (rx/filter (complement :skip-component-sync?)) + ;; Keep waits pending while component changes are checked. + (rx/map start-sync-barrier) (rx/observe-on :async)) - check-changes + get-component-events (fn [[event old-data]] - (cond - (nil? old-data) - (rx/empty) + (let [{:keys [file-id changes save-undo? undo-group]} event + changed-components + (when (and old-data + (or (nil? file-id) (= file-id (:id old-data)))) + (into #{} + (mapcat (partial ch/components-changed old-data)) + changes))] + (cond + (empty? changed-components) + (rx/empty) - (:translation? event) - (rx/empty) + save-undo? + (do + (log/info :hint "detected component changes" + :ids (map str changed-components) + :undo-group undo-group) + (->> (rx/from changed-components) + (rx/map #(component-changed + % (:id old-data) undo-group)))) - :else - (let [{:keys [file-id changes save-undo? undo-group]} event + :else + ;; Undos only bump :modified-at. + (->> (rx/from changed-components) + (rx/map touch-component))))) - changed-components - (when (or (nil? file-id) (= file-id (:id old-data))) - (->> changes - (map (partial ch/components-changed old-data)) - (reduce into #{})))] - - (if (d/not-empty? changed-components) - (if save-undo? - (do (log/info :hint "detected component changes" - :ids (map str changed-components) - :undo-group undo-group) - (->> (rx/from changed-components) - (rx/map #(component-changed % (:id old-data) undo-group)))) - ;; save-undo? false (undos): just bump :modified-at - (->> (rx/from changed-components) - (rx/map touch-component))) - - (rx/empty))))) - - changes-s - (->> changes-s + component-events-s + (->> commits-s (rx/with-latest-from workspace-buffer-s) - (rx/mapcat check-changes) + (rx/mapcat + (fn [[[event task] old-data]] + (->> (get-component-events [event old-data]) + (rx/finalize #(finish-sync-barrier! task))))) + ;; Close barriers left behind when the page shuts down. + (rx/finalize #(wrf/finish-tasks! @pending-sync-barriers*)) (rx/share)) notifier-s - (->> changes-s + (->> component-events-s (rx/debounce 5000) (rx/tap #(log/trc :hint "buffer initialized")))] (when (or (contains? cf/flags :component-thumbnails) (features/active-feature? state "render-wasm/v1")) (->> (rx/merge - changes-s + component-events-s ;; WASM only: render the thumbnail on every component ;; change so single edits (fill, etc.) update instantly. ;; Non-WASM persists on every render, so it stays on the ;; debounced path below to avoid per-edit backend posts. (if (features/active-feature? state "render-wasm/v1") - (->> changes-s + (->> component-events-s (rx/filter (ptk/type? ::component-changed)) (rx/map deref) (rx/map render-component-thumbnail-event)) @@ -1467,7 +1514,7 @@ ;; Persist to the server in batches, 5s after the user ;; goes idle. - (->> changes-s + (->> component-events-s (rx/filter (ptk/type? ::component-changed)) (rx/map deref) (rx/buffer-until notifier-s) @@ -1476,7 +1523,7 @@ (update-component-thumbnail component-id file-id)))) ;; Undo/redo emit touch-component instead. - (->> changes-s + (->> component-events-s (rx/filter (ptk/type? ::touch-component)) (rx/map deref) (rx/map render-component-thumbnail-event))) @@ -1631,5 +1678,3 @@ (rx/mapcat (fn [_] (rp/cmd! :get-file-libraries {:file-id file-id}))) (rx/map (partial cleanup-unlinked-libraries file-id)))))) - - diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index fde7e22d6f..bc7ef868eb 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.mcp (:require @@ -14,6 +14,7 @@ [app.main.broadcast :as mbc] [app.main.data.plugins :as dp] [app.main.data.profile :as du] + [app.main.data.workspace :as-alias dw] [app.main.store :as st] [app.plugins.register :as preg] [app.util.timers :as ts] @@ -132,7 +133,7 @@ (assoc :host (str (u/join cf/public-uri "plugins/mcp/")))) stopper-s (rx/merge - (rx/filter (ptk/type? :app.main.data.workspace/finalize-workspace) stream) + (rx/filter (ptk/type? ::dw/finalize-workspace) stream) (rx/filter (ptk/type? ::stop-mcp-plugin) stream)) extension #js {:getToken (constantly token) @@ -202,7 +203,7 @@ ptk/WatchEvent (watch [_ state stream] (let [stopper-s (rx/merge - (rx/filter (ptk/type? :app.main.data.workspace/finalize-workspace) stream) + (rx/filter (ptk/type? ::dw/finalize-workspace) stream) (rx/filter (ptk/type? ::init) stream)) session-id (get state :session-id) diff --git a/frontend/src/app/main/data/workspace/media.cljs b/frontend/src/app/main/data/workspace/media.cljs index b0fc60445e..f3a395a504 100644 --- a/frontend/src/app/main/data/workspace/media.cljs +++ b/frontend/src/app/main/data/workspace/media.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.media (:require diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index cb8be131cd..b78add32b7 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.modifiers "Events related with shapes transformations" @@ -15,6 +15,7 @@ [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] [app.common.geom.shapes :as gsh] + [app.common.logging :as log] [app.common.math :as mth] [app.common.types.component :as ctk] [app.common.types.container :as ctn] @@ -579,44 +580,57 @@ modifiers (calculate-modifiers state ignore-constraints ignore-snap-pixel modif-tree page-id params)] (assoc state :workspace-modifiers modifiers)))))) +(defn- without-nil-ids + "Drop nil-keyed entries from a modif-tree. A nil shape id (possible in + production builds, where the upstream asserts are elided) would crash + the WASM heap write with `uuid/get-u32` being called on nil." + [modif-tree] + (if (contains? modif-tree nil) + (do (log/warn :hint "modif-tree contains a nil shape id; ignoring entry") + (dissoc modif-tree nil)) + modif-tree)) + (defn- parse-structure-modifiers [modif-tree] (into [] - (mapcat - (fn [[parent-id data]] - (when (ctm/has-structure? (:modifiers data)) - (->> (concat - (get-in data [:modifiers :structure-parent]) - (get-in data [:modifiers :structure-child])) - (mapcat - (fn [modifier] - (case (:type modifier) - :remove-children - (->> (:value modifier) - (map (fn [child-id] - {:type :remove-children - :parent parent-id - :id child-id - :index 0 - :value 0}))) + (comp + (mapcat + (fn [[parent-id data]] + (when (ctm/has-structure? (:modifiers data)) + (->> (concat + (get-in data [:modifiers :structure-parent]) + (get-in data [:modifiers :structure-child])) + (mapcat + (fn [modifier] + (case (:type modifier) + :remove-children + (->> (:value modifier) + (map (fn [child-id] + {:type :remove-children + :parent parent-id + :id child-id + :index 0 + :value 0}))) - :add-children - (->> (:value modifier) - (map (fn [child-id] - {:type :add-children - :parent parent-id - :id child-id - :index (:index modifier) - :value 0}))) + :add-children + (->> (:value modifier) + (map (fn [child-id] + {:type :add-children + :parent parent-id + :id child-id + :index (:index modifier) + :value 0}))) - :scale-content - [{:type :scale-content - :parent parent-id - :id parent-id - :index 0 - :value (:value modifier)}] - nil))))))) + :scale-content + [{:type :scale-content + :parent parent-id + :id parent-id + :index 0 + :value (:value modifier)}] + nil))))))) + (filter (fn [{:keys [id parent]}] + (and (some? id) (some? parent))))) modif-tree)) @@ -624,7 +638,7 @@ (let [default-transform (gmt/matrix)] (keep (fn [[id data]] (cond - (= id uuid/zero) + (or (nil? id) (= id uuid/zero)) nil (ctm/has-geometry? (:modifiers data)) @@ -693,65 +707,66 @@ subtree-ids-by-id selection-rect-cache] :or {ignore-constraints false ignore-snap-pixel false} :as params}] - (ptk/reify ::set-wasm-modifiers - ptk/UpdateEvent - (update [_ state] - (let [property-changes (extract-property-changes modif-tree)] - (if (d/not-empty? property-changes) - (-> state - (assoc :prev-wasm-props (:wasm-props state)) - (assoc :wasm-props property-changes)) - state))) + (let [modif-tree (without-nil-ids modif-tree)] + (ptk/reify ::set-wasm-modifiers + ptk/UpdateEvent + (update [_ state] + (let [property-changes (extract-property-changes modif-tree)] + (if (d/not-empty? property-changes) + (-> state + (assoc :prev-wasm-props (:wasm-props state)) + (assoc :wasm-props property-changes)) + state))) - ptk/WatchEvent - (watch [_ state _] - ;; Entering an interactive transform (drag/resize/rotate). Flip - ;; the renderer into fast + atlas-backdrop mode so the live - ;; preview is cheap, tiles never appear sequentially and the main - ;; thread is not blocked. The pair is closed in - ;; `clear-local-transform`. - (ensure-interactive-transform-start!) - (let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) - translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] + ptk/WatchEvent + (watch [_ state _] + ;; Entering an interactive transform (drag/resize/rotate). Flip + ;; the renderer into fast + atlas-backdrop mode so the live + ;; preview is cheap, tiles never appear sequentially and the main + ;; thread is not blocked. The pair is closed in + ;; `clear-local-transform`. + (ensure-interactive-transform-start!) + (let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) + translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] - (if translation? - ;; Pure translation: no structure changes needed. If structure - ;; modifiers were active from a previous non-translation frame - ;; (e.g. shape hovered over a frame then dragged back out), - ;; clear them now so the shape is not clipped by the old frame. - (when @wasm-structure-modifiers-active? - (wasm.api/clean-modifiers) - (vreset! wasm-structure-modifiers-active? false)) - (let [objects (dsh/lookup-page-objects state)] - (set-wasm-props! objects (:prev-wasm-props state) (:wasm-props state)) - (wasm.api/clean-modifiers) - (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)) - (vreset! wasm-structure-modifiers-active? true))) - (let [geometry-entries (parse-geometry-modifiers modif-tree) - root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries) - wasm-ready? (wasm.api/initialized?) - ;; While the GL context is down (lost / mid-reload), keep the - ;; root transforms so SVG selection/preview can still move. - ;; `propagate-modifiers` returns [] when not ready, do not - ;; treat that as "no modifiers". - modifiers - (cond - (or (not wasm-ready?) - (and translation? (not snap-pixel?))) - root-modifiers + (if translation? + ;; Pure translation: no structure changes needed. If structure + ;; modifiers were active from a previous non-translation frame + ;; (e.g. shape hovered over a frame then dragged back out), + ;; clear them now so the shape is not clipped by the old frame. + (when @wasm-structure-modifiers-active? + (wasm.api/clean-modifiers) + (vreset! wasm-structure-modifiers-active? false)) + (let [objects (dsh/lookup-page-objects state)] + (set-wasm-props! objects (:prev-wasm-props state) (:wasm-props state)) + (wasm.api/clean-modifiers) + (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)) + (vreset! wasm-structure-modifiers-active? true))) + (let [geometry-entries (parse-geometry-modifiers modif-tree) + root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries) + wasm-ready? (wasm.api/initialized?) + ;; While the GL context is down (lost / mid-reload), keep the + ;; root transforms so SVG selection/preview can still move. + ;; `propagate-modifiers` returns [] when not ready, do not + ;; treat that as "no modifiers". + modifiers + (cond + (or (not wasm-ready?) + (and translation? (not snap-pixel?))) + root-modifiers - :else - (let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel?)] - (if (seq propagated) propagated root-modifiers)))] - (when wasm-ready? - (wasm.api/set-modifiers modifiers)) - (let [ids (into [] xf:map-key geometry-entries) - selrect (when wasm-ready? - (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers)) - (cached-translation-selrect ids (second (first modifiers)) selection-rect-cache) - (wasm.api/get-selection-rect ids)))] - (rx/of (set-temporary-selrect selrect) - (set-temporary-modifiers modifiers)))))))) + :else + (let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel?)] + (if (seq propagated) propagated root-modifiers)))] + (when wasm-ready? + (wasm.api/set-modifiers modifiers)) + (let [ids (into [] xf:map-key geometry-entries) + selrect (when wasm-ready? + (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers)) + (cached-translation-selrect ids (second (first modifiers)) selection-rect-cache) + (wasm.api/get-selection-rect ids)))] + (rx/of (set-temporary-selrect selrect) + (set-temporary-modifiers modifiers))))))))) (defn propagate-structure-modifiers [modif-tree objects] @@ -782,58 +797,44 @@ subtree-ids-by-id] :or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil undo-transation? true} :as params}] - (ptk/reify ::apply-wasm-modifiers - ptk/WatchEvent - (watch [_ state _] - (let [translation? - (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] - (wasm.api/clean-modifiers) - (when-not translation? - (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))) + (let [modif-tree (without-nil-ids modif-tree)] + (ptk/reify ::apply-wasm-modifiers + ptk/WatchEvent + (watch [_ state _] + (let [translation? + (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] + (wasm.api/clean-modifiers) + (when-not translation? + (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))) - ;; Apply property changes (e.g. grow-type) to WASM shapes before - ;; propagating geometry, so propagate_modifiers sees the updated state. - (doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)] - (when (= property :grow-type) - (wasm.api/use-shape id) - (wasm.api/set-shape-grow-type value))) + ;; Apply property changes (e.g. grow-type) to WASM shapes before + ;; propagating geometry, so propagate_modifiers sees the updated state. + (doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)] + (when (= property :grow-type) + (wasm.api/use-shape id) + (wasm.api/set-shape-grow-type value))) - (let [objects (dsh/lookup-page-objects state) + (let [objects (dsh/lookup-page-objects state) - geometry-entries - (parse-geometry-modifiers modif-tree) + geometry-entries + (parse-geometry-modifiers modif-tree) - snap-pixel? - (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) + snap-pixel? + (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) - transforms - (cond - (and translation? (not snap-pixel?)) - ;; Mirror WASM `propagate_modifiers` in CLJS: splat the - ;; translation matrix onto every descendant. Without - ;; this step the commit would only touch the dragged - ;; primaries and descendants would snap back to their - ;; pre-drag positions on drop. - ;; - ;; Skipped when `snap-pixel?` is on: WASM applies - ;; per-shape pixel correction (different scale/translate - ;; per descendant) which we can't replicate cheaply on - ;; the CLJS side. - (reduce - (fn [acc [id data]] - (let [t (:transform data) - subtree-ids - (or (get subtree-ids-by-id id) - (cfh/get-children-ids-with-self objects id))] - (reduce (fn [a sid] (assoc a sid t)) acc subtree-ids))) - {} - geometry-entries) - - ;; Context lost / mid-reload: do not call into WASM. Use - ;; root transforms (and splat translation onto descendants - ;; when we can) so the commit still lands in file data. - (not (wasm.api/initialized?)) - (if translation? + transforms + (cond + (and translation? (not snap-pixel?)) + ;; Mirror WASM `propagate_modifiers` in CLJS: splat the + ;; translation matrix onto every descendant. Without + ;; this step the commit would only touch the dragged + ;; primaries and descendants would snap back to their + ;; pre-drag positions on drop. + ;; + ;; Skipped when `snap-pixel?` is on: WASM applies + ;; per-shape pixel correction (different scale/translate + ;; per descendant) which we can't replicate cheaply on + ;; the CLJS side. (reduce (fn [acc [id data]] (let [t (:transform data) @@ -843,71 +844,87 @@ (reduce (fn [a sid] (assoc a sid t)) acc subtree-ids))) {} geometry-entries) - (into {} - (map (fn [[id data]] [id (:transform data)])) - geometry-entries)) - :else - (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))) + ;; Context lost / mid-reload: do not call into WASM. Use + ;; root transforms (and splat translation onto descendants + ;; when we can) so the commit still lands in file data. + (not (wasm.api/initialized?)) + (if translation? + (reduce + (fn [acc [id data]] + (let [t (:transform data) + subtree-ids + (or (get subtree-ids-by-id id) + (cfh/get-children-ids-with-self objects id))] + (reduce (fn [a sid] (assoc a sid t)) acc subtree-ids))) + {} + geometry-entries) + (into {} + (map (fn [[id data]] [id (:transform data)])) + geometry-entries)) - ignore-tree - (calculate-ignore-tree-wasm transforms objects) + :else + (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))) - options - (-> params - (assoc :reg-objects? true) - (assoc :ignore-tree ignore-tree) - (assoc :translation? translation?) - ;; Attributes that can change in the transform. This - ;; way we don't have to check all the attributes - (assoc :attrs transform-attrs)) + ignore-tree + (calculate-ignore-tree-wasm transforms objects) - modif-tree - (propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state)) + options + (-> params + (assoc :reg-objects? true) + (assoc :ignore-tree ignore-tree) + (assoc :translation? translation?) + ;; Attributes that can change in the transform. This + ;; way we don't have to check all the attributes + (assoc :attrs transform-attrs)) - ids - (into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms)) + modif-tree + (propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state)) - update-shape - (fn [shape] - (let [shape-id (dm/get-prop shape :id) - transform (get transforms shape-id) - modifiers (dm/get-in modif-tree [shape-id :modifiers])] - (-> shape - (gsh/apply-transform transform) - (ctm/apply-structure-modifiers modifiers)))) + ids + (into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms)) - bool-ids - (into #{} - (comp - (mapcat (partial cfh/get-parents-with-self objects)) - (filter cfh/bool-shape?) - (map :id)) - ids) + update-shape + (fn [shape] + (let [shape-id (dm/get-prop shape :id) + transform (get transforms shape-id) + modifiers (dm/get-in modif-tree [shape-id :modifiers])] + (-> shape + (gsh/apply-transform transform) + (ctm/apply-structure-modifiers modifiers)))) - undo-id (js/Symbol)] - (rx/concat - (if undo-transation? - (rx/of (dwu/start-undo-transaction undo-id)) - (rx/empty)) - (rx/of - (clear-local-transform) - (ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms}) - (ptk/event ::dwcm/move-frame-comment-threads transforms) - (dwsh/update-shapes ids update-shape options) + bool-ids + (into #{} + (comp + (mapcat (partial cfh/get-parents-with-self objects)) + (filter cfh/bool-shape?) + (map :id)) + ids) - ;; The update to the bool path needs to be in a different operation because it - ;; needs to have the updated children info. - ;; `update-layout? false`: recalculating a bool path can never change - ;; `:hidden`, and the layout check would recompute the whole boolean - ;; path in WASM once per bool shape just to find that out. - (dwsh/update-shapes bool-ids path/update-bool-shape (assoc options - :with-objects? true - :update-layout? false))) + undo-id (js/Symbol)] - (if undo-transation? - (rx/of (dwu/commit-undo-transaction undo-id)) - (rx/empty)))))))) + (rx/concat + (if undo-transation? + (rx/of (dwu/start-undo-transaction undo-id)) + (rx/empty)) + (rx/of + (clear-local-transform) + (ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms}) + (ptk/event ::dwcm/move-frame-comment-threads transforms) + (dwsh/update-shapes ids update-shape options) + + ;; The update to the bool path needs to be in a different operation because it + ;; needs to have the updated children info. + ;; `update-layout? false`: recalculating a bool path can never change + ;; `:hidden`, and the layout check would recompute the whole boolean + ;; path in WASM once per bool shape just to find that out. + (dwsh/update-shapes bool-ids path/update-bool-shape (assoc options + :with-objects? true + :update-layout? false))) + + (if undo-transation? + (rx/of (dwu/commit-undo-transaction undo-id)) + (rx/empty))))))))) (def ^:private xf-rotation-shape diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs index d2b41889d8..55f9735814 100644 --- a/frontend/src/app/main/data/workspace/notifications.cljs +++ b/frontend/src/app/main/data/workspace/notifications.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.notifications (:require diff --git a/frontend/src/app/main/data/workspace/pages.cljs b/frontend/src/app/main/data/workspace/pages.cljs index 260ebef9e1..44c3ccab94 100644 --- a/frontend/src/app/main/data/workspace/pages.cljs +++ b/frontend/src/app/main/data/workspace/pages.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.pages (:require diff --git a/frontend/src/app/main/data/workspace/path.cljs b/frontend/src/app/main/data/workspace/path.cljs index 8948147c02..de8decc1ba 100644 --- a/frontend/src/app/main/data/workspace/path.cljs +++ b/frontend/src/app/main/data/workspace/path.cljs @@ -2,11 +2,12 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path (:require [app.common.data.macros :as dm] + [app.main.data.workspace.path.clipboard :as clipboard] [app.main.data.workspace.path.drawing :as drawing] [app.main.data.workspace.path.edition :as edition] [app.main.data.workspace.path.selection :as selection] @@ -19,31 +20,59 @@ (dm/export drawing/close-path-drag-start) (dm/export drawing/change-edit-mode) (dm/export drawing/reset-last-handler) +(dm/export drawing/on-draw-node-pointer-down) +(dm/export drawing/on-draw-segment-pointer-down) +(dm/export drawing/start-move-prev-handler) ;; Edition (dm/export edition/start-move-handler) (dm/export edition/start-move-path-point) +(dm/export edition/start-move-path-segment) (dm/export edition/start-path-edit) (dm/export edition/create-node-at-position) (dm/export edition/move-selected) +;; Clipboard +(dm/export clipboard/copy-selected-nodes) +(dm/export clipboard/cut-selected-nodes) +(dm/export clipboard/paste-nodes) +(dm/export clipboard/duplicate-selected) + ;; Selection (dm/export selection/handle-area-selection) (dm/export selection/select-node) +(dm/export selection/select-segment) +(dm/export selection/select-handler) (dm/export selection/path-handler-enter) (dm/export selection/path-handler-leave) +(dm/export selection/path-segment-enter) +(dm/export selection/path-segment-leave) (dm/export selection/path-pointer-enter) (dm/export selection/path-pointer-leave) +(dm/export selection/select-all-nodes) +(dm/export selection/deselect-all) ;; Path tools (dm/export tools/make-curve) (dm/export tools/make-corner) (dm/export tools/add-node) (dm/export tools/remove-node) +(dm/export tools/delete-selected) +(dm/export tools/delete-selected-with-segments) (dm/export tools/merge-nodes) (dm/export tools/join-nodes) (dm/export tools/separate-nodes) (dm/export tools/toggle-snap) +(dm/export tools/set-handler-type) +(dm/export tools/toggle-node-curve) +(dm/export tools/toggle-segment-curve) +(dm/export tools/remove-segment) +(dm/export tools/remove-node-with-segments) +(dm/export tools/remove-handler) +(dm/export tools/flip-nodes) +(dm/export tools/align-nodes) +(dm/export tools/distribute-nodes) +(dm/export tools/set-selection-coordinate) ;; Undo/redo (dm/export undo/undo-path) diff --git a/frontend/src/app/main/data/workspace/path/changes.cljs b/frontend/src/app/main/data/workspace/path/changes.cljs index 5680b9402f..d47cf11230 100644 --- a/frontend/src/app/main/data/workspace/path/changes.cljs +++ b/frontend/src/app/main/data/workspace/path/changes.cljs @@ -2,90 +2,61 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.changes (:require - [app.common.data.macros :as dm] [app.common.files.changes-builder :as pcb] [app.common.types.path :as path] [app.main.data.changes :as dch] [app.main.data.helpers :as dsh] - [app.main.data.workspace.path.state :as st] + [app.main.data.workspace.shapes :as dwsh] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) -(defn generate-path-changes - "Generates changes to update the new content of the shape" - [it objects page-id shape old-content new-content] +(defn- normalize-content + "Normalizes path content for persistence." + [content preserve-move-to] + (-> (if (and (not preserve-move-to) + (= (-> content last :command) :move-to)) + (take (dec (count content)) content) + content) + (path/close-loops))) - (assert (path/content? old-content)) - (assert (path/content? new-content)) - - (let [shape-id (:id shape) - - ;; We set the old values so the update-shapes works - objects - (update objects shape-id - (fn [shape] - (-> shape - (assoc :content old-content) - (path/update-geometry)))) - - changes - (-> (pcb/empty-changes it page-id) - (pcb/with-objects objects)) - - new-content - (path/content new-content)] - - (cond - ;; https://tree.taiga.io/project/penpot/issue/2366 - (nil? shape-id) - changes - - (empty? new-content) - (-> changes - (pcb/remove-objects [shape-id]) - (pcb/resize-parents [shape-id])) - - :else - (-> changes - (pcb/update-shapes [shape-id] - (fn [shape] - (-> shape - (assoc :content new-content) - (path/update-geometry)))) - (pcb/resize-parents [shape-id]))))) - -(defn save-path-content - ([] - (save-path-content {})) - ([{:keys [preserve-move-to] :or {preserve-move-to false}}] - (ptk/reify ::save-path-content - ptk/UpdateEvent - (update [_ state] - (let [content (st/get-path state :content) - content (if (and (not preserve-move-to) - (= (-> content last :command) :move-to)) - (path/content (take (dec (count content)) content)) - (path/content content))] - (st/set-content state content))) - - ptk/WatchEvent - (watch [it state _] - (let [page-id (:current-page-id state) - local (get state :workspace-local) - id (get local :edition) - objects (dsh/lookup-page-objects state page-id)] - - ;; NOTE: we proceed only if the shape is present on the - ;; objects, if shape is a ephimeral drawing shape, we should - ;; do nothing - (when-let [shape (get objects id)] - (when-let [old-content (dm/get-in local [:edit-path id :old-content])] - (let [new-content (get shape :content) - changes (generate-path-changes it objects page-id shape old-content new-content)] - (rx/of (dch/commit-changes changes)))))))))) +(defn finalize-path-content + [id] + (ptk/reify ::finalize-path-content + ptk/WatchEvent + (watch [it state _] + (let [page-id (:current-page-id state) + objects (dsh/lookup-page-objects state page-id) + shape (get objects id) + old-content (get-in state [:workspace-local :edit-path id :old-content]) + edit-content (get-in state [:workspace-drawing :object :content]) + new-content (some-> edit-content (normalize-content false))] + (cond + ;; Ignore differences introduced only by normalization. + (or (nil? shape) + (nil? old-content) + (nil? edit-content) + (= old-content edit-content) + (= (path/close-loops old-content) new-content)) + (rx/empty) + (empty? new-content) + (let [changes (-> (pcb/empty-changes it page-id) + (pcb/with-objects objects) + (pcb/remove-objects [id]) + (pcb/resize-parents [id]))] + (rx/of (dch/commit-changes changes))) + :else + (rx/of + (dwsh/update-shapes + [id] + (fn [shape] + (-> shape + (path/convert-to-path) + (assoc :content new-content) + (path/update-geometry))) + {:reg-objects? true}))))))) diff --git a/frontend/src/app/main/data/workspace/path/clipboard.cljs b/frontend/src/app/main/data/workspace/path/clipboard.cljs new file mode 100644 index 0000000000..003b555e6b --- /dev/null +++ b/frontend/src/app/main/data/workspace/path/clipboard.cljs @@ -0,0 +1,209 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.main.data.workspace.path.clipboard + (:require + [app.common.data.macros :as dm] + [app.common.geom.point :as gpt] + [app.common.math :as mth] + [app.common.transit :as t] + [app.common.types.path :as path] + [app.main.data.helpers :as dsh] + [app.main.data.workspace.path.common :as common] + [app.main.data.workspace.path.drawing :as drawing] + [app.main.data.workspace.path.edition :as edition] + [app.main.data.workspace.path.helpers :as helpers] + [app.main.data.workspace.path.state :as st] + [app.main.data.workspace.path.tools :as tools] + [app.main.data.workspace.shapes :as dwsh] + [app.main.streams :as ms] + [app.util.clipboard :as clipboard] + [beicon.v2.core :as rx] + [potok.v2.core :as ptk])) + +(def ^:private clipboard-type :copied-path-content) + +(defn- on-clipboard-error + [cause] + (js/console.error "clipboard blocked:" cause) + (rx/empty)) + +(defn copy-selected-nodes + "Copies the selected path content to the clipboard." + [] + (ptk/reify ::copy-selected-nodes + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selection (st/get-selection state id) + fragment (some-> content (path/extract-content selection))] + (when (seq fragment) + (let [data (t/encode-str {:type clipboard-type + :content fragment} + {:type :json-verbose})] + (->> (rx/from (clipboard/to-clipboard data)) + (rx/catch on-clipboard-error) + (rx/ignore)))))))) + +(defn cut-selected-nodes + "Copies and removes the current path selection." + [] + (ptk/reify ::cut-selected-nodes + ptk/WatchEvent + (watch [_ _ _] + (rx/of (copy-selected-nodes) + (tools/delete-selected))))) + +(def ^:private paste-offset (gpt/point 10 10)) + +(defn collision-step + "Returns the non-negative paste-offset step that makes two nodes coincide." + [pasted existing] + (let [delta (gpt/subtract existing pasted) + x-step (/ (:x delta) (:x paste-offset)) + y-step (/ (:y delta) (:y paste-offset))] + (when (and (not (neg? x-step)) + (mth/close? x-step y-step) + (mth/close? x-step (mth/round x-step))) + (long (mth/round x-step))))) + +(defn available-offset-step + "Returns the first paste-offset step with no node collisions." + [existing pasted] + (let [blocked + (reduce + (fn [blocked pasted-point] + (reduce + (fn [blocked existing-point] + (if-let [step (collision-step pasted-point existing-point)] + (conj blocked step) + blocked)) + blocked + existing)) + #{} + pasted)] + ;; At most (count blocked) non-negative steps can be unavailable. + (some #(when-not (contains? blocked %) %) + (range (inc (count blocked)))))) + +(defn- center-content-at + "Centers `sub-content` on `target` using its node bounds." + [sub-content target] + (let [pts (path/get-points sub-content) + xs (map :x pts) + ys (map :y pts) + center (gpt/point (/ (+ (reduce min xs) (reduce max xs)) 2) + (/ (+ (reduce min ys) (reduce max ys)) 2))] + (path/move-content sub-content (gpt/subtract target center)))) + +(defn- offset-pasted-content + "Offsets pasted content until its nodes do not overlap existing nodes." + [content sub-content] + (let [existing (into #{} (path/get-points content)) + pasted (path/get-points sub-content) + step (available-offset-step existing pasted)] + (if (zero? step) + sub-content + (path/move-content sub-content (gpt/scale paste-offset step))))) + +(defn paste-content + "Pastes path content into the edited path at the pointer." + [sub-content] + (ptk/reify ::paste-content + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state)] + (if (and (some? id) + (some? (dm/get-in state [:workspace-local :edit-path id])) + (seq sub-content)) + (let [content (st/get-path state :content) + base (count content) + target (deref ms/mouse-position) + ;; Center the fragment at the pointer. + sub-content (cond-> sub-content + (some? target) (center-content-at target)) + sub-content (offset-pasted-content content sub-content) + new-content (path/splice-content content sub-content) + pasted (into #{} + (map #(+ base %)) + (helpers/node-indices sub-content))] + (-> state + (st/set-content new-content) + (update-in (st/get-path-location state) path/update-geometry) + (assoc-in [:workspace-local :edit-path id :selection] + (assoc helpers/empty-selection :nodes pasted)))) + state))) + + ptk/WatchEvent + (watch [_ state _] + ;; Enter move mode with the pasted nodes selected. + (when (some? (dm/get-in state [:workspace-local :edition])) + (rx/of (drawing/change-edit-mode :move)))))) + +(defn paste-nodes-as-shape + "Creates a path shape from copied content at the pointer." + [sub-content] + (ptk/reify ::paste-nodes-as-shape + ptk/WatchEvent + (watch [_ state _] + (let [content (path/content sub-content) + id (st/get-path-id state) + editing? (and (some? id) + (some? (dm/get-in state [:workspace-local :edit-path id])))] + (when (and (not editing?) (seq (path/get-points content))) + (let [target (or (deref ms/mouse-position) + (dsh/get-viewport-center state)) + moved (center-content-at content target) + mrect (path/calc-selrect moved)] + (rx/of + (dwsh/create-and-add-shape + :path (:x target) (:y target) + {:content moved + ;; Keep the shape at the content position. + :x (:x mrect) + :y (:y mrect) + :width (:width mrect) + :height (:height mrect) + :name "Path"})))))))) + +(defn paste-nodes + "Pastes copied path content into the edited path." + [] + (ptk/reify ::paste-nodes + ptk/WatchEvent + (watch [_ _ _] + (->> (clipboard/from-navigator) + (rx/filter #(= (.-type ^js %) "application/transit+json")) + (rx/mapcat #(rx/from (.text ^js %))) + (rx/map t/decode-str) + (rx/filter #(and (map? %) (= clipboard-type (:type %)))) + (rx/take 1) + (rx/mapcat (fn [{:keys [content]}] + ;; Drop a pending segment before splicing. + (rx/of (common/cancel-pending-segment) + (paste-content content)))) + (rx/catch on-clipboard-error))))) + +(defn duplicate-selected + "Duplicates the current node and segment selection." + [] + (ptk/reify ::duplicate-selected + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selection (st/get-selection state id) + zoom (dm/get-in state [:workspace-local :zoom] 1) + result (helpers/duplicate-selection-content + content selection (edition/duplicate-offset zoom))] + (when (seq (:sub result)) + (rx/concat + ;; Drop a pending segment before splicing. + (rx/of (common/cancel-pending-segment) + (edition/splice-duplicated result)) + (when (some? (dm/get-in state [:workspace-local :edition])) + (rx/of (drawing/change-edit-mode :move))))))))) diff --git a/frontend/src/app/main/data/workspace/path/common.cljs b/frontend/src/app/main/data/workspace/path/common.cljs index cfb59be0a8..e2f3a06c61 100644 --- a/frontend/src/app/main/data/workspace/path/common.cljs +++ b/frontend/src/app/main/data/workspace/path/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.common (:require @@ -17,6 +17,23 @@ [state] (dissoc state :last-point :prev-handler :drag-handler :preview)) +(defn- drop-trailing-move-to + "Drops a trailing subpath start without segments." + [content] + (if (= :move-to (-> content last :command)) + (path/content (take (dec (count content)) content)) + content)) + +(defn- update-object-content + [state f] + (let [location (st/get-path-location state) + object (get-in state location) + content (some-> (:content object) f)] + (cond-> state + (some? content) + (assoc-in location (cond-> (assoc object :content content) + (seq content) (path/update-geometry)))))) + (defn finish-path [] (ptk/reify ::finish-path @@ -25,4 +42,15 @@ (let [id (st/get-path-id state)] (-> state (update-in [:workspace-local :edit-path id] clean-edit-state) - (update-in (st/get-path-location state :content) path/close-subpaths)))))) + (update-object-content (comp path/close-subpaths drop-trailing-move-to))))))) + +(defn cancel-pending-segment + "Cancels the pending segment without leaving draw mode." + [] + (ptk/reify ::cancel-pending-segment + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state)] + (-> state + (update-in [:workspace-local :edit-path id] clean-edit-state) + (update-object-content drop-trailing-move-to)))))) diff --git a/frontend/src/app/main/data/workspace/path/drawing.cljs b/frontend/src/app/main/data/workspace/path/drawing.cljs index 4caa22959a..fa4db3cf7f 100644 --- a/frontend/src/app/main/data/workspace/path/drawing.cljs +++ b/frontend/src/app/main/data/workspace/path/drawing.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.drawing (:require @@ -20,13 +20,14 @@ [app.main.data.workspace.drawing.common :as dwdc] [app.main.data.workspace.edition :as dwe] [app.main.data.workspace.pages :as-alias dwpg] - [app.main.data.workspace.path.changes :as changes] [app.main.data.workspace.path.common :as common] + [app.main.data.workspace.path.edition :as edition] [app.main.data.workspace.path.helpers :as helpers] [app.main.data.workspace.path.state :as st] [app.main.data.workspace.path.streams :as streams] + [app.main.data.workspace.path.tools :as tools] [app.main.data.workspace.path.undo :as undo] - [app.main.data.workspace.shapes :as dwsh] + [app.main.streams :as ms] [app.util.mouse :as mse] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) @@ -35,36 +36,75 @@ (declare check-changed-content) (declare change-edit-mode) -(defn- end-path-event? +(defn start-created-path-edition + [id] + (ptk/reify ::start-created-path-edition + ptk/WatchEvent + (watch [_ _ _] + (rx/of (dwe/start-edition-mode id) + (edition/start-path-edit id) + (change-edit-mode :draw))))) + +;; Draw-loop stop signals either restart the same path or exit drawing. + +(defn restart-draw-loop? + "True when drawing restarts on the same path." + [event] + (or (= (ptk/type event) ::common/finish-path) + (and ^boolean (mse/mouse-event? event) + ^boolean (mse/mouse-double-click-event? event)))) + +(defn- exit-draw-loop? + "True when the draw loop exits." [event] (let [type (ptk/type event)] - (or - (= type ::common/finish-path) - (= type :app.main.data.workspace.path.shortcuts/esc-pressed) - (= type :app.main.data.workspace.common/clear-edition-mode) - (= type :app.main.data.workspace.edition/clear-edition-mode) - (= type ::dwpg/finalize-page) - (= event :interrupt) ;; ESC - (and ^boolean (mse/mouse-event? event) - ^boolean (mse/mouse-double-click-event? event))))) + (or (= type ::dwe/clear-edition-mode) + (= type ::dwpg/finalize-page) + (dwe/interrupt? event)))) + +(defn- end-path-event? + "True when the draw loop should stop." + [event] + (or (restart-draw-loop? event) + (exit-draw-loop? event))) + +(def ^:private draw-insert-threshold + "Maximum screen distance for inserting a node on a segment." + 16) (defn preview-next-point [{:keys [x y shift?]}] (ptk/reify ::preview-next-point ptk/UpdateEvent (update [_ state] - (let [id (st/get-path-id state) - fix-angle? shift? - last-point (get-in state [:workspace-local :edit-path id :last-point]) - position (cond-> (gpt/point x y) - fix-angle? (path.helpers/position-fixed-angle last-point)) - content (st/get-path state :content) + (let [id (st/get-path-id state) + edit-path (get-in state [:workspace-local :edit-path id])] + ;; Freeze the next-point preview during modifier drags. + (if (seq (:content-modifiers edit-path)) + state + (let [fix-angle? shift? + {:keys [last-point prev-handler]} edit-path + content (st/get-path state :content) + zoom (dm/get-in state [:workspace-local :zoom] 1) + raw-pos @ms/mouse-position - {:keys [last-point prev-handler]} - (get-in state [:workspace-local :edit-path id]) + ;; Segment insertion uses the exact on-curve preview point. + insert-point (when (and (seq (:segments (:hover edit-path))) + (gpt/point? raw-pos)) + (helpers/insertion-point + content raw-pos (/ draw-insert-threshold zoom) true)) - segment (path/next-node content position last-point prev-handler)] - (assoc-in state [:workspace-local :edit-path id :preview] segment))))) + position (cond + (some? insert-point) + insert-point + + fix-angle? + (path.helpers/position-fixed-angle (gpt/point x y) last-point) + + :else + (gpt/point x y)) + segment (path/next-node content position last-point prev-handler)] + (assoc-in state [:workspace-local :edit-path id :preview] segment))))))) (defn add-node [{:keys [x y shift?]}] @@ -141,34 +181,84 @@ (rx/of (preview-next-point handler) (undo/merge-head)))))) -(defn close-path-drag-start - [position] - (ptk/reify ::close-path-drag-start +(defn drag-prev-handler + "Moves the current node's forward handle while drawing." + [{:keys [x y alt? shift?]}] + (ptk/reify ::drag-prev-handler + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + content (st/get-path state :content) + index (count content) + position (path.helpers/segment->point (nth content (dec index))) + + handler-position + (cond-> (gpt/point x y) + shift? (path.helpers/position-fixed-angle position)) + + dx (- (:x handler-position) (:x position)) + dy (- (:y handler-position) (:y position)) + + ;; Alt leaves the opposite handle unchanged. + rejoin? (not alt?) + + modifiers (helpers/move-handler-modifiers content index :c1 false false rejoin? dx dy)] + (-> state + (update-in [:workspace-local :edit-path id] dissoc :prev-handler) + (assoc-in [:workspace-local :edit-path id :content-modifiers] modifiers) + (assoc-in [:workspace-local :edit-path id :drag-handler] handler-position)))))) + +(defn start-move-prev-handler + "Starts dragging the current node's forward handle." + [] + (ptk/reify ::start-move-prev-handler ptk/WatchEvent (watch [_ state stream] - (let [content (st/get-path state :content) - handlers (-> (path/get-handlers content) - (get position)) + (let [stopper (rx/merge + (mse/drag-stopper stream) + (rx/filter end-path-event? stream)) - [idx prefix] (when (= (count handlers) 1) - (first handlers)) - - drag-events-stream + drag-events (->> (streams/position-stream state) - (rx/map #(drag-handler position idx prefix %)) - (rx/take-until - (rx/merge - (mse/drag-stopper stream) - (rx/filter end-path-event? stream))))] + (rx/map drag-prev-handler) + (rx/take-until stopper))] + (streams/drag-stream + (rx/concat + (rx/of (edition/set-drag-cursor "move-handles")) + drag-events + (rx/of (finish-drag)))))))) - (rx/concat - (rx/of (add-node position)) - (streams/drag-stream - (rx/concat - drag-events-stream - (rx/of (finish-drag)) - (rx/of (close-path-drag-end)))) - (rx/of (common/finish-path))))))) +(defn close-path-drag-start + ([position] + (close-path-drag-start position "draw-node")) + ([position cursor] + (ptk/reify ::close-path-drag-start + ptk/WatchEvent + (watch [_ state stream] + (let [content (st/get-path state :content) + handlers (-> (path/get-handlers content) + (get position)) + + [idx prefix] (when (= (count handlers) 1) + (first handlers)) + + drag-events-stream + (->> (streams/position-stream state) + (rx/map #(drag-handler position idx prefix %)) + (rx/take-until + (rx/merge + (mse/drag-stopper stream) + (rx/filter end-path-event? stream))))] + + (rx/concat + (rx/of (add-node position)) + (streams/drag-stream + (rx/concat + (rx/of (edition/set-drag-cursor cursor)) + drag-events-stream + (rx/of (finish-drag)) + (rx/of (close-path-drag-end)))) + (rx/of (common/finish-path)))))))) (defn close-path-drag-end [] (ptk/reify ::close-path-drag-end @@ -177,30 +267,27 @@ (let [id (st/get-path-id state)] (update-in state [:workspace-local :edit-path id] dissoc :prev-handler))))) -(defn start-path-from-point [position] - (ptk/reify ::start-path-from-point - ptk/WatchEvent - (watch [_ state stream] - (let [stopper (rx/merge - (mse/drag-stopper stream) - (rx/filter end-path-event? stream)) +(defn start-path-from-point + ([position] + (start-path-from-point position "draw-node")) + ([position cursor] + (ptk/reify ::start-path-from-point + ptk/WatchEvent + (watch [_ state stream] + (let [stopper (rx/merge + (mse/drag-stopper stream) + (rx/filter end-path-event? stream)) - drag-events (->> (streams/position-stream state) - (rx/map #(drag-handler %)) - (rx/take-until stopper))] - (rx/concat - (rx/of (add-node position)) - (streams/drag-stream - (rx/concat - drag-events - (rx/of (finish-drag))))))))) - -(defn make-node-events-stream - [stream] - (->> stream - (rx/filter (ptk/type? ::close-path-drag-start)) - (rx/take 1) - (rx/merge-map #(rx/empty)))) + drag-events (->> (streams/position-stream state) + (rx/map #(drag-handler %)) + (rx/take-until stopper))] + (rx/concat + (rx/of (add-node position)) + (streams/drag-stream + (rx/concat + (rx/of (edition/set-drag-cursor cursor)) + drag-events + (rx/of (finish-drag)))))))))) (defn make-drag-stream [state stream down-event] @@ -229,7 +316,12 @@ ptk/UpdateEvent (update [_ state] (let [id (st/get-path-id state)] - (assoc-in state [:workspace-local :edit-path id :edit-mode] :draw))) + (update-in state [:workspace-local :edit-path id] + (fn [edit-state] + (-> edit-state + (assoc :edit-mode :draw) + ;; Keep explicit snap choices across draw restarts. + (update :snap-toggled (fnil identity true))))))) ptk/WatchEvent (watch [_ state stream] @@ -243,26 +335,29 @@ (rx/filter end-path-event?) (rx/share)) + stop-event + (volatile! nil) + stoper-stream (->> stream (rx/filter (ptk/type? ::start-edition)) - (rx/merge end-stream)) + (rx/merge end-stream) + (rx/tap #(vreset! stop-event %)) + (rx/share)) ;; Mouse move preview mousemove-events (->> (streams/position-stream state) (rx/map #(preview-next-point %))) - ;; From mouse down we can have: click, drag and double click + ;; Viewport clicks add nodes; node clicks handle closing separately. mousedown-events (->> mouse-down ;; We just ignore the mouse event and stream down the ;; last position event (rx/with-latest-from #(-> %2) (streams/position-stream state)) - ;; We change to the stream that emits the first event (rx/switch-map - #(rx/race (make-node-events-stream stream) - (make-drag-stream state stream %))) + #(make-drag-stream state stream %)) (rx/take-until end-stream))] (->> (rx/concat @@ -270,7 +365,11 @@ (->> (rx/merge mousemove-events mousedown-events) (rx/take-until stoper-stream)) - (rx/of (ptk/data-event ::end-edition)))))))) + (->> (rx/of nil) + (rx/map (fn [_] + (ptk/data-event + ::end-edition + {:restart? (restart-draw-loop? @stop-event)})))))))))) (defn setup-frame [] @@ -299,8 +398,20 @@ (cond-> (some? drop-index) (with-meta {:index drop-index}))))))))) +(defn- close-drawn-loops + "Adds explicit close commands to completed loops." + [] + (ptk/reify ::close-drawn-loops + ptk/UpdateEvent + (update [_ state] + (d/update-in-when state [:workspace-drawing :object] + (fn [object] + (-> object + (update :content path/close-loops) + (path/update-geometry))))))) + (defn- handle-drawing-end - [shape-id] + [shape-id restart?] (ptk/reify ::handle-drawing-end ptk/UpdateEvent (update [_ state] @@ -313,15 +424,26 @@ ptk/WatchEvent (watch [_ state _] (when-let [content (dm/get-in state [:workspace-drawing :object :content])] - (if (> (count content) 1) - (rx/of (setup-frame) + (cond + (and (> (count content) 1) restart?) + (rx/of (common/finish-path) + (close-drawn-loops) + (setup-frame) (dwdc/handle-finish-drawing) - (dwe/start-edition-mode shape-id) - (change-edit-mode :draw)) - (rx/of (dwdc/handle-finish-drawing))))))) + (start-created-path-edition shape-id)) + + (> (count content) 1) + (rx/of (close-drawn-loops) + (setup-frame) + (dwdc/handle-finish-drawing) + (dwe/clear-edition-mode)) + + :else + (rx/of (dwdc/handle-finish-drawing) + (dwe/clear-edition-mode))))))) (defn handle-drawing - "Hanndle the start of drawing new path shape" + "Starts drawing a path." [] (ptk/reify ::handle-new-shape ptk/UpdateEvent @@ -337,8 +459,10 @@ (->> stream (rx/filter (ptk/type? ::end-edition)) (rx/take 1) + ;; Let the stop event settle before finishing the drawing. (rx/observe-on :async) - (rx/map (partial handle-drawing-end shape-id)))))))) + (rx/map (fn [event] + (handle-drawing-end shape-id (:restart? (deref event))))))))))) (declare start-draw-mode*) @@ -347,12 +471,22 @@ (ptk/reify ::start-draw-mode ptk/UpdateEvent (update [_ state] - (let [id (dm/get-in state [:workspace-local :edition]) - objects (dsh/lookup-page-objects state) - content (dm/get-in objects [id :content])] - (if content - (update-in state [:workspace-local :edit-path id] assoc :old-content content) - state))) + (let [id (dm/get-in state [:workspace-local :edition]) + objects (dsh/lookup-page-objects state) + shape (get objects id) + drawing (dm/get-in state [:workspace-drawing :object]) + old-content (dm/get-in state [:workspace-local :edit-path id :old-content]) + drawing (or drawing + (some-> shape + (path/convert-to-path objects) + (update :content path/close-subpaths) + (path/update-geometry)))] + (cond-> state + drawing + (assoc-in [:workspace-drawing :object] drawing) + + (and drawing (nil? old-content)) + (assoc-in [:workspace-local :edit-path id :old-content] (:content drawing))))) ptk/WatchEvent (watch [_ _ _] @@ -369,23 +503,52 @@ (if (= :draw mode) (rx/concat - (rx/of (dwsh/update-shapes [id] path/convert-to-path)) (rx/of (start-edition id)) (->> stream (rx/filter (ptk/type? ::end-edition)) (rx/take 1) - (rx/mapcat (fn [_] - (rx/of (check-changed-content) - (start-draw-mode*)))))) + (rx/mapcat (fn [event] + (if (:restart? (deref event)) + (rx/of (common/finish-path) + (check-changed-content) + (start-draw-mode*)) + (rx/empty)))))) (rx/empty)))))) +(defn- enter-draw-from-selected-node + "Starts a new segment from the only selected node." + [state id] + (let [selection (get (st/get-selection state id) :nodes #{}) + last-point (dm/get-in state [:workspace-local :edit-path id :last-point]) + content (st/get-path state :content)] + (if (and (nil? last-point) + (= 1 (count selection)) + (some? content) + (helpers/node? content (first selection))) + (let [index (first selection) + pos (helpers/node-position content index) + last-idx (dec (count content)) + tip? (and (= index last-idx) + (not= :close-path (:command (nth content index nil)))) + state (assoc-in state [:workspace-local :edit-path id :last-point] pos)] + (if tip? + state + (update-in state (st/get-path-location state) + (fn [shape] + (-> shape + (update :content path/append-segment + {:command :move-to :params (select-keys pos [:x :y])}) + (path/update-geometry)))))) + state))) + (defn change-edit-mode [mode] (ptk/reify ::change-edit-mode ptk/UpdateEvent (update [_ state] (if-let [id (dm/get-in state [:workspace-local :edition])] - (d/update-in-when state [:workspace-local :edit-path id] assoc :edit-mode mode) + (cond-> (d/update-in-when state [:workspace-local :edit-path id] assoc :edit-mode mode) + (= mode :draw) (enter-draw-from-selected-node id)) state)) ptk/WatchEvent @@ -405,6 +568,98 @@ (let [id (st/get-path-id state)] (assoc-in state [:workspace-local :edit-path id :prev-handler] nil))))) +(defn on-draw-node-pointer-down + "Handles node clicks and drags in draw mode." + [index position alt? mod?] + (ptk/reify ::on-draw-node-pointer-down + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + content (st/get-path state :content) + node-pos (when (and (some? content) + (< index (count content)) + (helpers/node? content index)) + (helpers/node-position content index)) + last-point (dm/get-in state [:workspace-local :edit-path id :last-point]) + pending-origin? (and (some? node-pos) (= last-point node-pos))] + (cond + (and mod? alt?) + (rx/concat + (rx/of (tools/remove-node-with-segments index)) + (if pending-origin? + (rx/of (common/cancel-pending-segment)) + (rx/empty))) + + mod? + (streams/drag-stream + (rx/of (edition/set-drag-cursor "move-handles") + (edition/curve-config-node-drag index)) + (rx/of (tools/toggle-node-curve index))) + + alt? + (if (some? node-pos) + (rx/concat + (rx/of (tools/remove-node node-pos)) + (if pending-origin? + (rx/of (common/cancel-pending-segment)) + (rx/empty))) + (rx/empty)) + + (= last-point position) + (rx/of (reset-last-handler)) + + (nil? last-point) + (rx/of (start-path-from-point position)) + + :else + (rx/of (close-path-drag-start position))))))) + +(defn on-draw-segment-pointer-down + "Handles segment clicks and drags in draw mode." + [index alt? mod?] + (ptk/reify ::on-draw-segment-pointer-down + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + zoom (dm/get-in state [:workspace-local :zoom] 1) + content (st/get-path state :content) + position @ms/mouse-position + last-point (dm/get-in state [:workspace-local :edit-path id :last-point])] + (cond + alt? + (let [entry (d/seek #(= index (:index %)) (helpers/segment-entries content)) + pending-here? (and (some? last-point) + (some? entry) + (or (= last-point (:from entry)) + (= last-point (:to entry))))] + (rx/concat + (rx/of (tools/remove-segment index)) + (if pending-here? + (rx/of (common/cancel-pending-segment)) + (rx/empty)))) + + mod? + (let [entry (d/seek #(= index (:index %)) (helpers/segment-entries content)) + bendable? (and (some? entry) + (not= :close-path (:command (:segment entry))))] + (streams/drag-stream + (if bendable? + (rx/of (edition/set-drag-cursor "move-curve") + (edition/bend-selected-segment index position)) + (rx/empty)) + (rx/of (tools/toggle-segment-curve index)))) + + :else + (let [insert-point (helpers/insertion-point + content position (/ draw-insert-threshold zoom) true)] + (if (some? insert-point) + (rx/concat + (rx/of (edition/create-node-at-position (meta insert-point))) + (if (some? last-point) + (rx/of (close-path-drag-start insert-point "draw-add")) + (rx/of (start-path-from-point insert-point "draw-add")))) + (rx/empty)))))))) + (defn check-changed-content [] (ptk/reify ::check-changed-content @@ -418,10 +673,11 @@ (cond (and (not= content old-content) (not empty-content?)) - (rx/of (changes/save-path-content)) + (rx/empty) + ;; Exit through the path edition stop event. (= mode :draw) - (rx/of :interrupt) + (rx/of (dwe/clear-edition-mode)) :else (rx/of diff --git a/frontend/src/app/main/data/workspace/path/edition.cljs b/frontend/src/app/main/data/workspace/path/edition.cljs index d70fff5cb6..d35434da55 100644 --- a/frontend/src/app/main/data/workspace/path/edition.cljs +++ b/frontend/src/app/main/data/workspace/path/edition.cljs @@ -2,16 +2,16 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.edition (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] [app.common.types.path :as path] [app.common.types.path.helpers :as path.helpers] - [app.main.data.changes :as dch] [app.main.data.helpers :as dsh] [app.main.data.workspace.edition :as dwe] [app.main.data.workspace.path.changes :as changes] @@ -19,32 +19,178 @@ [app.main.data.workspace.path.selection :as selection] [app.main.data.workspace.path.state :as st] [app.main.data.workspace.path.streams :as streams] + [app.main.data.workspace.path.tools :as tools] [app.main.data.workspace.path.undo :as undo] - [app.main.data.workspace.shapes :as dwsh] [app.main.streams :as ms] + [app.render-wasm.svg-fills :as svg-fills] [app.util.mouse :as mse] [beicon.v2.core :as rx] + [beicon.v2.operators :as rxo] [potok.v2.core :as ptk])) -(defn modify-handler [id index prefix dx dy match-opposite?] - (ptk/reify ::modify-handler +(defn- handler-modifier-delta + [modifiers index prefix] + (let [[cx cy] (path.helpers/prefix->coords prefix)] + (gpt/point (dm/get-in modifiers [index cx] 0) + (dm/get-in modifiers [index cy] 0)))) + +(defn- remove-handler-modifier + [modifiers [index prefix]] + (let [[cx cy] (path.helpers/prefix->coords prefix) + modifiers (update modifiers index dissoc cx cy)] + (cond-> modifiers + (empty? (get modifiers index)) (dissoc index)))) + +(defn- stored-handler-drag-mode + "Returns a handler's stored drag mode, ignoring stale mirror state." + [content handler-types index prefix] + (case (get handler-types (helpers/handler-node-index index prefix)) + :mirror (if (helpers/handlers-joined? content index prefix) + :mirror + :smart) + :aligned :aligned + :independent :independent + :smart)) + +(defn- active-selected-handlers + "Returns valid handlers for the current drag." + [content primary selected-handlers move-selection?] + (let [handlers (if move-selection? selected-handlers #{primary}) + handlers (into #{} + (filter (fn [[index _]] + (= :curve-to (:command (nth content index nil))))) + handlers)] + (cond-> handlers + (empty? handlers) (conj primary)))) + +(defn- handler-drag-modifiers + "Returns modifiers for one dragged handler." + [content handler-types selected-handlers start-modifiers move-delta mode + move-selection? [index prefix]] + (let [start-delta (handler-modifier-delta start-modifiers index prefix) + delta (gpt/add start-delta move-delta) + opposite-id (path/opposite-index content index prefix) + opposite-selected? (and move-selection? + (contains? selected-handlers opposite-id)) + joined? (helpers/handlers-joined? content index prefix) + handler-mode (if move-selection? + (stored-handler-drag-mode + content handler-types index prefix) + mode) + modifiers (case handler-mode + :aligned + (helpers/align-handler-modifiers + content index prefix (:x delta) (:y delta)) + + :mirror + (helpers/move-handler-modifiers + content index prefix true true true (:x delta) (:y delta)) + + :independent + (helpers/move-handler-modifiers + content index prefix false false false (:x delta) (:y delta)) + + (helpers/move-handler-modifiers + content index prefix false + (and joined? (not opposite-selected?)) + false (:x delta) (:y delta)))] + (cond-> modifiers + opposite-selected? (remove-handler-modifier opposite-id)))) + +(defn- selected-handler-modifiers + "Combines modifiers for all dragged handlers." + [content handler-types selected-handlers start-modifiers move-delta mode move-selection?] + (reduce + (fn [modifiers handler-id] + (d/deep-merge + modifiers + (handler-drag-modifiers + content handler-types selected-handlers start-modifiers move-delta + mode move-selection? handler-id))) + {} + selected-handlers)) + +(defn- transient-prev-handler + "Returns the mirrored transient drawing handler." + [content [index prefix] handler-mode moving-handler edit-mode prev-handler] + (when (and (= edit-mode :draw) + (= prefix :c2) + (= index (dec (count content))) + (some? prev-handler) + (not= handler-mode :independent)) + (let [node (path/handler->node content index prefix) + mode (if (= handler-mode :mirror) :mirror :aligned)] + (helpers/opposite-handler-target node moving-handler prev-handler mode)))) + +(defn modify-selected-handlers + "Moves selected handlers using each node's handler mode." + [id primary start-modifiers dx dy mode move-selection?] + (ptk/reify ::modify-selected-handlers ptk/UpdateEvent (update [_ state] - - (let [content (st/get-path state :content) - modifiers (helpers/move-handler-modifiers content index prefix false match-opposite? dx dy) - [cx cy] (if (= prefix :c1) [:c1x :c1y] [:c2x :c2y]) - point (gpt/point (+ (dm/get-in content [index :params cx]) dx) - (+ (dm/get-in content [index :params cy]) dy))] - + (let [content (st/get-path state :content) + handler-types (dm/get-in state + [:workspace-local :edit-path id :handler-types] + {}) + selected-handlers (active-selected-handlers + content primary + (dm/get-in state + [:workspace-local :edit-path id :selection :handlers] + #{}) + move-selection?) + move-delta (gpt/point dx dy) + moved-modifiers (selected-handler-modifiers + content handler-types selected-handlers start-modifiers + move-delta mode move-selection?) + modifiers (d/deep-merge start-modifiers moved-modifiers) + [primary-index primary-prefix] primary + primary-mode (if move-selection? + (stored-handler-drag-mode + content handler-types primary-index primary-prefix) + mode) + primary-handler (path/get-handler-point content primary-index primary-prefix) + primary-delta (gpt/add + (handler-modifier-delta start-modifiers + primary-index + primary-prefix) + move-delta) + moving-handler (gpt/add primary-handler primary-delta) + edit-mode (dm/get-in state [:workspace-local :edit-path id :edit-mode]) + prev-handler (dm/get-in state [:workspace-local :edit-path id :prev-handler]) + new-prev-handler (transient-prev-handler + content primary primary-mode moving-handler + edit-mode prev-handler)] (-> state - (update-in [:workspace-local :edit-path id :content-modifiers] merge modifiers) - (assoc-in [:workspace-local :edit-path id :moving-handler] point)))))) + (assoc-in [:workspace-local :edit-path id :content-modifiers] modifiers) + (assoc-in [:workspace-local :edit-path id :moving-handler] moving-handler) + (cond-> (some? new-prev-handler) + (assoc-in [:workspace-local :edit-path id :prev-handler] new-prev-handler))))))) + +(defn- apply-content-modifiers* + [id new-content] + (ptk/reify ::apply-content-modifiers* + ptk/UpdateEvent + (update [_ state] + (cond-> (-> state + (st/set-content new-content) + (update-in [:workspace-local :edit-path id] + dissoc + :content-modifiers + :moving-nodes + :moving-handler)) + (seq new-content) + (update-in (st/get-path-location state) path/update-geometry))) + + ptk/WatchEvent + (watch [_ _ _] + ;; Moving modifiers keep node indices stable. + (when (empty? new-content) + (rx/of (dwe/clear-edition-mode)))))) (defn apply-content-modifiers [] (ptk/reify ::apply-content-modifiers ptk/WatchEvent - (watch [it state _] + (watch [_ state _] (let [id (st/get-path-id state) shape (st/get-path state) @@ -52,24 +198,47 @@ (dm/get-in state [:workspace-local :edit-path id :content-modifiers])] (if (or (nil? shape) (nil? content-modifiers)) (rx/of (dwe/clear-edition-mode)) - (let [page-id (get state :current-page-id state) - objects (dsh/lookup-page-objects state) + (let [content (get shape :content) + new-content (path/apply-content-modifiers content content-modifiers)] + (when (some? new-content) + (rx/of (apply-content-modifiers* id new-content))))))))) - content (get shape :content) - new-content (path/apply-content-modifiers content content-modifiers) +(def ^:private merge-drop-distance + "Maximum screen distance for merging dropped nodes." + 10) - old-points (path/get-points content) - new-points (path/get-points new-content) - point-change (->> (map hash-map old-points new-points) (reduce merge))] +(defn merge-dragged-on-drop + "Merges the closest moved and stationary nodes after a drag." + [] + (ptk/reify ::merge-dragged-on-drop + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selection (st/get-selection state id) - (when (and (some? new-content) (some? shape)) - (let [changes (changes/generate-path-changes it objects page-id shape (:content shape) new-content)] - (if (empty? new-content) - (rx/of (dch/commit-changes changes) - (dwe/clear-edition-mode)) - (rx/of (dch/commit-changes changes) - (selection/update-selection point-change) - (fn [state] (update-in state [:workspace-local :edit-path id] dissoc :content-modifiers :moving-nodes :moving-handler)))))))))))) + ;; Include endpoints of selected segments. + moved-indices (into (get selection :nodes #{}) + (helpers/segment-node-indices content (get selection :segments #{}))) + moved (helpers/node-positions content moved-indices) + moved-set (set moved) + + zoom (dm/get-in state [:workspace-local :zoom] 1) + threshold (/ merge-drop-distance zoom) + others (remove moved-set (path/get-points content)) + + pairs (->> moved + (keep (fn [p] + (let [near (filter #(<= (gpt/distance % p) threshold) others)] + (when (seq near) + (let [t (apply min-key #(gpt/distance % p) near)] + [p t (gpt/distance t p)])))))) + best (when (seq pairs) + (apply min-key #(nth % 2) pairs))] + (if (some? best) + (let [[p t _] best] + (rx/of (tools/process-path-tool #{p t} path/merge-nodes))) + (rx/empty)))))) (defn modify-content-point [content {dx :x dy :y} modifiers point] @@ -93,59 +262,184 @@ (reduce modify-handler $ handler-indices)))) (defn set-move-modifier - [points move-modifier] + "Adds a move delta for selected nodes and handlers." + [points handler-ids move-modifier] (ptk/reify ::set-modifiers ptk/UpdateEvent (update [_ state] - (let [id (st/get-path-id state) + (let [id (st/get-path-id state) content (st/get-path state :content) - modifiers-reducer (partial modify-content-point content move-modifier) + {dx :x dy :y} move-modifier + content-modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers] {}) - content-modifiers (->> points - (reduce modifiers-reducer content-modifiers))] + + content-modifiers + (->> points + (reduce (partial modify-content-point content move-modifier) content-modifiers)) + + content-modifiers + (->> handler-ids + (reduce (fn [modifiers [index prefix]] + (let [cx (d/prefix-keyword prefix :x) + cy (d/prefix-keyword prefix :y)] + (update modifiers index assoc cx dx cy dy))) + content-modifiers))] (-> state (assoc-in [:workspace-local :edit-path id :content-modifiers] content-modifiers)))))) +(defn- move-node-indices + [state node-indices from-point to-point] + (let [id (st/get-path-id state) + content (st/get-path state :content) + to-point (cond-> to-point + (:shift? to-point) (path.helpers/position-fixed-angle from-point)) + delta (gpt/subtract to-point from-point) + points (helpers/node-positions content node-indices) + reducer (partial modify-content-point content delta) + modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers] {}) + modifiers (reduce reducer modifiers points)] + (-> state + (assoc-in [:workspace-local :edit-path id :moving-nodes] true) + (assoc-in [:workspace-local :edit-path id :content-modifiers] modifiers)))) + (defn move-selected-path-point [from-point to-point] (ptk/reify ::move-point ptk/UpdateEvent (update [_ state] - (let [id (st/get-path-id state) - content (st/get-path state :content) - to-point (cond-> to-point - (:shift? to-point) (path.helpers/position-fixed-angle from-point)) + (let [id (st/get-path-id state) + selected-nodes (dm/get-in state + [:workspace-local :edit-path id :selection :nodes] + #{})] + (move-node-indices state selected-nodes from-point to-point))))) - delta (gpt/subtract to-point from-point) +(defn move-selected-path-segment [from-point to-point] + (ptk/reify ::move-segment + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selection (st/get-selection state id) + node-indices (helpers/selected-node-indices content selection)] + (move-node-indices state node-indices from-point to-point))))) - modifiers-reducer (partial modify-content-point content delta) +(defn- clear-drag-cursor [] + (ptk/reify ::clear-drag-cursor + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state)] + (d/update-in-when state [:workspace-local :edit-path id] dissoc :drag-cursor))))) - points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{}) +(defn set-drag-cursor + "Shows `cursor` until the current drag stops." + [cursor] + (ptk/reify ::set-drag-cursor + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state)] + (d/update-in-when state [:workspace-local :edit-path id] assoc :drag-cursor cursor))) - modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers] {}) - modifiers (->> points - (reduce modifiers-reducer modifiers))] - - (-> state - (assoc-in [:workspace-local :edit-path id :moving-nodes] true) - (assoc-in [:workspace-local :edit-path id :content-modifiers] modifiers)))))) + ptk/WatchEvent + (watch [_ _ stream] + (->> (rx/merge + (mse/drag-stopper stream) + (rx/filter streams/finish-edition? stream)) + (rx/take 1) + (rx/map #(clear-drag-cursor)))))) (declare drag-selected-points) +(def ^:private duplicate-screen-offset 10) + +(defn duplicate-offset + "Returns a duplicate offset that stays constant in screen pixels." + [zoom] + (let [step (/ duplicate-screen-offset zoom)] + (gpt/point step step))) + +(defn splice-duplicated + "Adds duplicate subpaths and selects their new nodes." + [{:keys [sub selected]}] + (ptk/reify ::splice-duplicated + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state)] + (if (and (some? id) (seq sub)) + (let [content (st/get-path state :content) + base (count content) + new-content (path/splice-content content sub) + pasted (into #{} (map #(+ base %)) selected)] + (-> state + (st/set-content new-content) + (update-in (st/get-path-location state) path/update-geometry) + (assoc-in [:workspace-local :edit-path id :selection] + (assoc helpers/empty-selection :nodes pasted)))) + state))))) + +(defn- duplicate-and-drag + "Duplicates the selection and drags the copy from `start-position`." + [start-position] + (ptk/reify ::duplicate-and-drag + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selection (st/get-selection state id) + zoom (dm/get-in state [:workspace-local :zoom] 1) + result (helpers/duplicate-selection-content + content selection (duplicate-offset zoom))] + (if (seq (:sub result)) + (rx/of (splice-duplicated result) + (drag-selected-points start-position)) + (rx/of (drag-selected-points start-position))))))) + +(declare curve-config-node-drag) + (defn start-move-path-point - [position shift?] + "Handles node clicks and drags in move mode." + [index shift? alt? mod?] (ptk/reify ::start-move-path-point ptk/WatchEvent (watch [_ state _] - (let [id (dm/get-in state [:workspace-local :edition]) - selected-points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{}) - selected? (contains? selected-points position)] - (streams/drag-stream - (rx/of - (dwsh/update-shapes [id] path/convert-to-path) - (when-not selected? (selection/select-node position shift?)) - (drag-selected-points @ms/mouse-position)) - (rx/of (selection/select-node position shift?))))))) + (let [id (st/get-path-id state) + selected-nodes (get (st/get-selection state id) :nodes #{}) + selected? (contains? selected-nodes index) + content (st/get-path state :content) + position (when (and (some? content) + (< index (count content)) + (helpers/node? content index)) + (helpers/node-position content index))] + (cond + (and mod? alt?) + (streams/drag-stream + (rx/empty) + (if (some? position) + (rx/of (tools/remove-node-with-segments index)) + (rx/empty))) + + mod? + (streams/drag-stream + (rx/of (set-drag-cursor "move-handles") + (curve-config-node-drag index)) + (rx/of (tools/toggle-node-curve index))) + + alt? + (streams/drag-stream + (rx/of + (set-drag-cursor "move-copy") + (when-not selected? (selection/select-node index false)) + (duplicate-and-drag @ms/mouse-position)) + (if (some? position) + (rx/of (tools/remove-node position)) + (rx/of (selection/select-node index false)))) + + :else + (streams/drag-stream + (rx/of + (set-drag-cursor "move-move") + (when-not selected? (selection/select-node index shift?)) + (drag-selected-points @ms/mouse-position)) + (rx/of (selection/select-node index shift?)))))))) (defn drag-selected-points [start-position] @@ -156,11 +450,13 @@ id (dm/get-in state [:workspace-local :edition]) - selected-points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{}) + content (st/get-path state :content) + + selected-nodes (get (st/get-selection state id) :nodes #{}) + selected-points (helpers/node-positions content selected-nodes) start-position (apply min-key #(gpt/distance start-position %) selected-points) - content (st/get-path state :content) points (path/get-points content)] (rx/concat @@ -169,8 +465,216 @@ (streams/move-points-stream start-position selected-points) (rx/map #(move-selected-path-point start-position %)) (rx/take-until stopper)) + (rx/of (apply-content-modifiers) + (merge-dragged-on-drop))))))) + +(declare drag-selected-segments) +(declare bend-selected-segment) +(declare create-node-at-position) + +(defn start-move-path-segment + "Handles segment clicks and drags in move mode." + [index shift? alt? mod?] + (ptk/reify ::start-move-path-segment + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + zoom (dm/get-in state [:workspace-local :zoom] 1) + content (st/get-path state :content) + selection (st/get-selection state id) + selected-segments (get selection :segments #{}) + ;; Both selected endpoints also select their segment for dragging. + segment-ends (helpers/segment-node-indices content #{index}) + selected? (or (contains? selected-segments index) + (and (seq segment-ends) + (every? (get selection :nodes #{}) segment-ends))) + position @ms/mouse-position + threshold (/ helpers/segment-insert-threshold zoom)] + (cond + (and mod? alt?) + (streams/drag-stream + (rx/empty) + (rx/of (tools/remove-segment index))) + + mod? + (let [entry (d/seek #(= index (:index %)) (helpers/segment-entries content)) + bend? (and (some? entry) + (not= :close-path (:command (:segment entry))))] + (streams/drag-stream + (rx/of (set-drag-cursor "move-curve") + (if bend? + (bend-selected-segment index position) + (drag-selected-segments position))) + (rx/of (tools/toggle-segment-curve index)))) + + alt? + (let [insert-point (helpers/insertion-point content position threshold true)] + (streams/drag-stream + (rx/of + (set-drag-cursor "move-copy") + (when-not selected? (selection/select-segment index false)) + (duplicate-and-drag position)) + (if (some? insert-point) + (rx/of (create-node-at-position (meta insert-point))) + (rx/of (selection/select-segment index false))))) + + :else + (let [insert-point (when-not shift? + (helpers/insertion-point content position threshold false)) + click-event (if (some? insert-point) + (create-node-at-position (meta insert-point)) + (selection/select-segment index shift?))] + (streams/drag-stream + (rx/of + (set-drag-cursor "move-move") + (when-not selected? (selection/select-segment index shift?)) + (drag-selected-segments position)) + (rx/of click-event)))))))) + +(defn- segment-entry + [content index] + (d/seek #(= index (:index %)) (helpers/segment-entries content))) + +(defn drag-selected-segments + [start-position] + (ptk/reify ::drag-selected-segments + ptk/WatchEvent + (watch [_ state stream] + (let [stopper (mse/drag-stopper stream) + id (dm/get-in state [:workspace-local :edition]) + content (st/get-path state :content) + selection (st/get-selection state id) + node-indices (helpers/selected-node-indices content selection) + selected-points (helpers/node-positions content node-indices) + points (path/get-points content)] + (if (empty? selected-points) + (rx/empty) + (rx/concat + (->> points + (streams/move-points-stream start-position selected-points) + (rx/map #(move-selected-path-segment start-position %)) + (rx/take-until stopper)) + (rx/of (apply-content-modifiers) + (merge-dragged-on-drop)))))))) + +(defn bend-segment-modifier + "Bends segment `index` so its point at `t` reaches `target`." + [index base-curve t target] + (ptk/reify ::bend-segment-modifier + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + deltas (path.helpers/bend-curve-deltas base-curve t target) + modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers] {})] + (assoc-in state [:workspace-local :edit-path id :content-modifiers] + (assoc modifiers index deltas)))))) + +(defn bend-selected-segment + [index start-position] + (ptk/reify ::bend-selected-segment + ptk/WatchEvent + (watch [_ state stream] + (let [stopper (mse/drag-stopper stream) + content (st/get-path state :content) + entry (segment-entry content index) + base-curve (path.helpers/entry->bezier entry) + ;; Keep the grabbed curve parameter fixed during the drag. + t (path.helpers/curve-closest-t base-curve start-position 0.001)] + (rx/concat + (->> ms/mouse-position + (rx/filter gpt/point?) + (rx/map streams/to-pixel-snap) + (rx/map #(bend-segment-modifier index base-curve t %)) + (rx/take-until stopper)) (rx/of (apply-content-modifiers))))))) +(defn- curve-config-modifier + "Pulls out smooth node handles toward `position`." + [node in-index in-base in-neighbour out-index out-base out-neighbour position] + (ptk/reify ::curve-config-modifier + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + v (gpt/to-vec node position) + both? (and (some? in-index) (some? out-index)) + + ;; Pick which handle follows the pointer from the drag direction. + ref (when (and (some? in-neighbour) (some? out-neighbour)) + (gpt/subtract (gpt/unit (gpt/to-vec node out-neighbour)) + (gpt/unit (gpt/to-vec node in-neighbour)))) + s (if (and both? (some? ref) (neg? (gpt/dot v ref))) -1 1) + + out-handle (if both? (gpt/add node (gpt/scale v s)) (gpt/add node v)) + in-handle (if both? (gpt/subtract node (gpt/scale v s)) (gpt/add node v)) + + modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers] {}) + modifiers (cond-> modifiers + (some? in-index) + (assoc in-index + {:c2x (- (:x in-handle) (:x in-base)) + :c2y (- (:y in-handle) (:y in-base))}) + + (some? out-index) + (assoc out-index + {:c1x (- (:x out-handle) (:x out-base)) + :c1y (- (:y out-handle) (:y out-base))}))] + (assoc-in state [:workspace-local :edit-path id :content-modifiers] modifiers))))) + +(defn curve-config-node-drag + "Replaces a node's handles with a smooth mirrored pair during a drag." + [index] + (ptk/reify ::curve-config-node-drag + ptk/WatchEvent + (watch [_ state stream] + (let [content (st/get-path state :content) + node (when (and (some? content) + (< index (count content)) + (helpers/node? content index)) + (helpers/node-position content index)) + in-cmd (nth content index nil) + out-cmd (nth content (inc index) nil) + in? (contains? #{:line-to :curve-to} (:command in-cmd)) + out? (contains? #{:line-to :curve-to} (:command out-cmd)) + ;; New curve handles start at the node. + in-base (when in? + (if (= :curve-to (:command in-cmd)) + (path/get-handler in-cmd :c2) + node)) + out-base (when out? + (if (= :curve-to (:command out-cmd)) + (path/get-handler out-cmd :c1) + node)) + ;; Neighbours keep handles on their matching leg. + in-neighbour (when in? (helpers/node-position content (dec index))) + out-neighbour (when out? (helpers/node-position content (inc index))) + stopper (rx/merge + (mse/drag-stopper stream) + (->> stream + (rx/filter streams/finish-edition?)))] + (if (and (some? node) (or in? out?)) + (rx/concat + (->> ms/mouse-position + (rx/filter gpt/point?) + ;; Apply Shift changes without waiting for pointer movement. + (rx/combine-latest-with ms/keyboard-shift) + (rx/map (fn [[position shift?]] + (assoc position :shift? shift?))) + (rx/map + (fn [{:keys [x y shift?]}] + (let [position (cond-> (gpt/point x y) + shift? (path.helpers/position-fixed-angle node))] + (curve-config-modifier node + (when in? index) + in-base + in-neighbour + (when out? (inc index)) + out-base + out-neighbour + position)))) + (rx/take-until stopper)) + (rx/of (apply-content-modifiers))) + (rx/empty)))))) + (defn- get-displacement "Retrieve the correct displacement delta point for the provided direction speed and distances thresholds." @@ -213,7 +717,20 @@ current-move (dm/get-in state [:workspace-local :edit-path id :current-move])] ;; id can be null if we just selected the tool but we didn't start drawing (if (and id (= same-event current-move)) - (let [points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{}) + (let [content (st/get-path state :content) + selection (st/get-selection state id) + selected-nodes (get selection :nodes #{}) + selected-segments (get selection :segments #{}) + selected-handlers (get selection :handlers #{}) + + ;; Move nodes rigidly and handlers independently. + node-indices (into selected-nodes + (helpers/segment-node-indices content selected-segments)) + points (helpers/node-positions content node-indices) + handler-ids (into #{} + (filter (fn [[index _]] + (= :curve-to (:command (nth content index nil))))) + selected-handlers) move-events (->> stream (rx/filter (ptk/type? ::move-selected)) @@ -226,12 +743,11 @@ mov-vec (gpt/multiply (get-displacement direction) scale)] (rx/concat - (rx/of (dwsh/update-shapes [id] path/convert-to-path)) (rx/merge (->> move-events (rx/take-until stopper) (rx/scan #(gpt/add %1 mov-vec) (gpt/point 0 0)) - (rx/map #(set-move-modifier points %))) + (rx/map #(set-move-modifier points handler-ids %))) ;; First event is not read by the stream so we need to send it again (rx/of (move-selected direction shift?))) @@ -240,53 +756,141 @@ (finish-move-selected)))) (rx/empty))))))) +(declare drag-selected-handlers) + +(defn- handler-drag-mode + "Returns the live handler matching mode for a drag." + [plain-mode mod? alt?] + (cond + (and mod? alt?) :aligned + mod? :mirror + alt? :independent + :else plain-mode)) + +(defn- handler-drag-cursor + [mod? alt?] + (if (or mod? alt?) "move-handles" "move-move")) + (defn start-move-handler - [index prefix] + "Handles handler clicks and drags in both edit modes." + [index prefix shift? alt? mod?] (ptk/reify ::start-move-handler + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + handler-id [index prefix] + content (st/get-path state :content) + selected-handlers (dm/get-in state + [:workspace-local :edit-path id :selection :handlers] + #{}) + selected? (contains? selected-handlers handler-id) + + handler-types (dm/get-in state [:workspace-local :edit-path id :handler-types] {}) + plain-mode (stored-handler-drag-mode + content handler-types index prefix)] + (cond + (and mod? alt?) + (streams/drag-stream + (rx/of (set-drag-cursor (handler-drag-cursor mod? alt?)) + (drag-selected-handlers handler-id plain-mode)) + (rx/empty)) + + (or mod? alt?) + (streams/drag-stream + (rx/of (set-drag-cursor (handler-drag-cursor mod? alt?)) + (drag-selected-handlers handler-id plain-mode)) + (rx/of (tools/remove-handler index prefix))) + + :else + (streams/drag-stream + (rx/of + (set-drag-cursor (handler-drag-cursor mod? alt?)) + (when-not selected? + (selection/select-handler index prefix shift?)) + (drag-selected-handlers handler-id plain-mode)) + (rx/of (selection/select-handler index prefix shift?)))))))) + +(defn drag-selected-handlers + "Drags selected handlers using the live matching mode." + [[index prefix :as primary] plain-mode] + (ptk/reify ::drag-selected-handlers ptk/WatchEvent (watch [_ state stream] - (let [id (dm/get-in state [:workspace-local :edition]) - cx (d/prefix-keyword prefix :x) - cy (d/prefix-keyword prefix :y) - - modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers]) - start-delta-x (dm/get-in modifiers [index cx] 0) - start-delta-y (dm/get-in modifiers [index cy] 0) - - content (st/get-path state :content) - points (path/get-points content) - - point (-> content (nth (if (= prefix :c1) (dec index) index)) (path.helpers/segment->point)) - handler (-> content (nth index) (path/get-handler prefix)) - + (let [id (st/get-path-id state) + content (st/get-path state :content) + points (path/get-points content) + start-modifiers (dm/get-in state + [:workspace-local :edit-path id :content-modifiers] + {}) + start-delta (handler-modifier-delta start-modifiers index prefix) + point (path/handler->node content index prefix) + handler (-> (path/get-handler-point content index prefix) + (gpt/add start-delta)) [op-idx op-prefix] (path/opposite-index content index prefix) - opposite (path/get-handler-point content op-idx op-prefix)] + opposite (when op-idx + (-> (path/get-handler-point content op-idx op-prefix) + (gpt/add (handler-modifier-delta start-modifiers + op-idx + op-prefix)))) + stopper (rx/merge + (mse/drag-stopper stream) + (->> stream + (rx/filter streams/finish-edition?))) - (streams/drag-stream - (rx/concat - (rx/of (dwsh/update-shapes [id] path/convert-to-path)) - (->> (streams/move-handler-stream handler point handler opposite points) + handler-events (rx/share + (streams/move-handler-stream handler point handler opposite points))] + (rx/concat + (rx/merge + (->> handler-events (rx/map - (fn [{:keys [x y alt? shift?]}] - (let [pos (cond-> (gpt/point x y) - shift? (path.helpers/position-fixed-angle point))] - (modify-handler - id - index - prefix - (+ start-delta-x (- (:x pos) (:x handler))) - (+ start-delta-y (- (:y pos) (:y handler))) - (not alt?))))) - (rx/take-until - (rx/merge - (mse/drag-stopper stream) - (->> stream - (rx/filter streams/finish-edition?))))) - - (rx/concat (rx/of (apply-content-modifiers))))))))) + (fn [{:keys [x y shift? alt? mod?]}] + (let [position (cond-> (gpt/point x y) + shift? (path.helpers/position-fixed-angle point)) + delta (gpt/subtract position handler) + mode (handler-drag-mode plain-mode mod? alt?) + move-selection? (not (or mod? alt?))] + (modify-selected-handlers id + primary + start-modifiers + (:x delta) + (:y delta) + mode + move-selection?)))) + (rx/take-until stopper)) + ;; Update the cursor only when the matching mode changes. + (->> handler-events + (rx/map (fn [{:keys [alt? mod?]}] (handler-drag-cursor mod? alt?))) + (rx/pipe (rxo/distinct-contiguous)) + (rx/map set-drag-cursor) + (rx/take-until stopper))) + (rx/of (apply-content-modifiers))))))) (declare stop-path-edit) +(defn resolve-edit-fills + "Resolves the fills inherited by the editing copy. + Frames stop group fill inheritance." + [shape objects] + (let [own (svg-fills/resolve-shape-fills shape)] + (if (seq own) + own + (loop [parent-id (:parent-id shape) + visited #{}] + (cond + (nil? parent-id) [] + (visited parent-id) [] + :else + (let [parent (get objects parent-id)] + (cond + (nil? parent) [] + (cfh/group-shape? parent) (let [fills (svg-fills/resolve-shape-fills parent)] + (if (seq fills) + fills + (recur (:parent-id parent) + (conj visited parent-id)))) + (cfh/frame-shape? parent) [] + :else (recur (:parent-id parent) + (conj visited parent-id))))))))) (defn start-path-edit [id] @@ -294,10 +898,15 @@ ptk/UpdateEvent (update [_ state] (let [objects (dsh/lookup-page-objects state) - shape (get objects id)] + shape (get objects id) + shape (-> shape + (path/convert-to-path objects) + (update :content path/close-subpaths) + (path/update-geometry)) + shape (assoc shape :fills (resolve-edit-fills shape objects))] (-> state - (st/set-content (path/close-subpaths (:content shape))) + (assoc-in [:workspace-drawing :object] shape) (update-in [:workspace-local :edit-path id] (fn [state] (let [state (if state @@ -305,20 +914,20 @@ (assoc state :edit-mode :draw) state) {:edit-mode :move - :selected #{} - :snap-toggled false})] + :selection helpers/empty-selection + :hover helpers/empty-selection + :handler-types {} + :snap-toggled true})] (assoc state :old-content (:content shape)))))))) ptk/WatchEvent (watch [_ _ stream] - (let [stopper (rx/filter #(let [type (ptk/type %)] - (= type ::dwe/clear-edition-mode) - (= type ::start-path-edit)) - stream)] + (let [stopper (rx/filter (ptk/type? ::start-path-edit) stream)] (rx/concat (rx/of (undo/start-path-undo)) + ;; Finalize once on the canonical edition stop event. (->> stream - (rx/filter #(= % :interrupt)) + (rx/filter (ptk/type? ::dwe/clear-edition-mode)) (rx/take 1) (rx/map #(stop-path-edit id)) (rx/take-until stopper))))))) @@ -326,29 +935,27 @@ (defn stop-path-edit [id] (ptk/reify ::stop-path-edit - ptk/UpdateEvent - (update [_ state] - (update state :workspace-local dissoc :edit-path id)) - ptk/WatchEvent (watch [_ _ _] - (rx/of (ptk/data-event :layout/update {:ids [id]}))))) + (rx/of + (changes/finalize-path-content id) + (fn [state] + (-> state + (update-in [:workspace-local :edit-path] dissoc id) + (update :workspace-drawing dissoc :object :lock))) + (ptk/data-event :layout/update {:ids [id]}))))) (defn- split-segments - [id {:keys [from-p to-p t]}] + [_id {:keys [from-p to-p t]}] (ptk/reify ::split-segments ptk/UpdateEvent (update [_ state] (let [content (st/get-path state :content)] (-> state - (assoc-in [:workspace-local :edit-path id :old-content] content) (st/set-content (-> content (path/split-segments #{from-p to-p} t) - (path/content)))))) - - ptk/WatchEvent - (watch [_ _ _] - (rx/of (changes/save-path-content {:preserve-move-to true}))))) + (path/content))) + (update-in (st/get-path-location state) path/update-geometry)))))) (defn create-node-at-position [params] @@ -356,5 +963,4 @@ ptk/WatchEvent (watch [_ state _] (let [id (st/get-path-id state)] - (rx/of (dwsh/update-shapes [id] path/convert-to-path) - (split-segments id params)))))) + (rx/of (split-segments id params)))))) diff --git a/frontend/src/app/main/data/workspace/path/helpers.cljs b/frontend/src/app/main/data/workspace/path/helpers.cljs index 4432cb0b08..2557cd0e5e 100644 --- a/frontend/src/app/main/data/workspace/path/helpers.cljs +++ b/frontend/src/app/main/data/workspace/path/helpers.cljs @@ -2,11 +2,15 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.helpers (:require + [app.common.data :as d] [app.common.geom.point :as gpt] + [app.common.geom.rect :as grc] + [app.common.geom.shapes :as gsh] + [app.common.geom.shapes.intersect :as gsi] [app.common.math :as mth] [app.common.types.path :as path] [app.common.types.path.helpers :as path.helpers])) @@ -25,8 +29,27 @@ (gpt/to-vec common p1) (gpt/to-vec common p2)))) +(defn opposite-handler-target + "Returns the opposite handler target for mirror or aligned modes." + [node handler opposite mode] + (if (and (some? node) (some? handler) (some? opposite)) + (case mode + :mirror + (gpt/subtract (gpt/scale node 2) handler) + + :aligned + (let [handler-vector (gpt/to-vec node handler)] + (if (mth/almost-zero? (gpt/length handler-vector)) + opposite + (gpt/subtract node + (gpt/scale (gpt/unit handler-vector) + (gpt/distance node opposite))))) + + opposite) + opposite)) + (defn- calculate-opposite-delta [node handler opposite match-angle? match-distance? dx dy] - (when (and (some? handler) (some? opposite)) + (if (and (some? handler) (some? opposite)) (let [;; To match the angle, the angle should be matching (angle between points 180deg) angle-handlers (angle-points node handler opposite) @@ -54,30 +77,466 @@ match-distance? (gpt/scale-from node distance-scale))] [(- (:x new-opposite) (:x opposite)) - (- (:y new-opposite) (:y opposite))]))) + (- (:y new-opposite) (:y opposite))]) + ;; Leave missing opposite handles unchanged. + [0 0])) + +(defn handlers-joined? + "True when a node's handlers are collinear and opposite." + [content index prefix] + (let [[op-idx op-prefix] (path/opposite-index content index prefix) + node (path/handler->node content index prefix) + handler (path/get-handler-point content index prefix) + opposite (when op-idx (path/get-handler-point content op-idx op-prefix))] + (boolean + (and (some? op-idx) + (some? handler) + (some? opposite) + (not= handler node) + (not= opposite node) + (<= (mth/abs (- 180 (angle-points node handler opposite))) 0.1))))) (defn move-handler-modifiers - [content index prefix match-distance? match-angle? dx dy] + ([content index prefix match-distance? match-angle? dx dy] + (move-handler-modifiers content index prefix match-distance? match-angle? false dx dy)) + ([content index prefix match-distance? match-angle? rejoin? dx dy] - (let [[cx cy] (path.helpers/prefix->coords prefix) + (let [[cx cy] (path.helpers/prefix->coords prefix) + [op-idx op-prefix] (path/opposite-index content index prefix) + + node (path/handler->node content index prefix) + handler (path/get-handler-point content index prefix) + opposite (path/get-handler-point content op-idx op-prefix) + + [ocx ocy] (path.helpers/prefix->coords op-prefix) + [odx ody] (calculate-opposite-delta node handler opposite match-angle? match-distance? dx dy) + + hnv (if (some? handler) + (gpt/to-vec node (-> handler (update :x + dx) (update :y + dy))) + (gpt/point dx dy)) + mirrored-opposite (opposite-handler-target + node (gpt/add node hnv) opposite :mirror)] + + (-> {} + (update index assoc cx dx cy dy) + + (cond-> + ;; Force an exact mirror when rejoining handlers. + (and (some? op-idx) rejoin? (not= opposite node)) + (update op-idx assoc + ocx (- (:x mirrored-opposite) (:x opposite)) + ocy (- (:y mirrored-opposite) (:y opposite))) + + (and (some? op-idx) (not rejoin?) (not= opposite node)) + (update op-idx assoc ocx odx ocy ody) + + (and (some? op-idx) (= opposite node) match-distance? match-angle?) + (update op-idx assoc + ocx (- (:x mirrored-opposite) (:x opposite)) + ocy (- (:y mirrored-opposite) (:y opposite)))))))) + +(defn align-handler-modifiers + "Moves a handler and aligns its opposite without changing its length." + [content index prefix dx dy] + (let [[cx cy] (path.helpers/prefix->coords prefix) [op-idx op-prefix] (path/opposite-index content index prefix) + node (path/handler->node content index prefix) + opposite (when (some? op-idx) + (path/get-handler-point content op-idx op-prefix)) + handler (path/get-handler-point content index prefix) + modifiers (-> {} (update index assoc cx dx cy dy))] + (if (and (some? handler) (some? opposite) (not= opposite node)) + (let [moved-handler (-> handler (update :x + dx) (update :y + dy)) + handler-vector (gpt/to-vec node moved-handler) + target (opposite-handler-target node moved-handler opposite :aligned)] + (if (mth/almost-zero? (gpt/length handler-vector)) + modifiers + (let [[ocx ocy] (path.helpers/prefix->coords op-prefix)] + (update modifiers op-idx assoc + ocx (- (:x target) (:x opposite)) + ocy (- (:y target) (:y opposite)))))) + modifiers))) - node (path/handler->node content index prefix) - handler (path/get-handler-point content index prefix) - opposite (path/get-handler-point content op-idx op-prefix) +;; --- Per-node handler type (mirror / aligned / independent) - [ocx ocy] (path.helpers/prefix->coords op-prefix) - [odx ody] (calculate-opposite-delta node handler opposite match-angle? match-distance? dx dy) +(defn handler-node-index + "Returns the anchor command index for a handler." + [index prefix] + (if (= prefix :c1) (dec index) index)) - hnv (if (some? handler) - (gpt/to-vec node (-> handler (update :x + dx) (update :y + dy))) - (gpt/point dx dy))] +(defn node-primary-handler + "Returns a curve handler for a node, preferring its incoming handle." + [content node-index] + (let [n (count content) + out-idx (inc node-index)] + (cond + (and (>= node-index 0) (< node-index n) + (= :curve-to (:command (nth content node-index nil)))) + [node-index :c2] - (-> {} - (update index assoc cx dx cy dy) + (and (< out-idx n) + (= :curve-to (:command (nth content out-idx nil)))) + [out-idx :c1] - (cond-> (and (some? op-idx) (not= opposite node)) - (update op-idx assoc ocx odx ocy ody) + :else nil))) - (and (some? op-idx) (= opposite node) match-distance? match-angle?) - (update op-idx assoc ocx (- (:x hnv)) ocy (- (:y hnv))))))) +(defn handlers-equal-length? + "True when a node's two handlers are the same distance from the node." + [content index prefix] + (let [[op-idx op-prefix] (path/opposite-index content index prefix) + node (path/handler->node content index prefix) + handler (path/get-handler-point content index prefix) + opposite (when op-idx (path/get-handler-point content op-idx op-prefix))] + (boolean + (and (some? handler) (some? opposite) + (mth/almost-zero? (- (gpt/distance node handler) + (gpt/distance node opposite))))))) + +(defn derive-handler-type + "Infers a node's handler type from its geometry." + [content node-index] + (if-let [[idx prefix] (node-primary-handler content node-index)] + (cond + (not (handlers-joined? content idx prefix)) :independent + (handlers-equal-length? content idx prefix) :mirror + :else :aligned) + :independent)) + +(defn remap-handler-types + "Remaps handler types by node position after structural changes." + [handler-types old-content new-content] + (let [handler-types (or handler-types {})] + (if (= (count old-content) (count new-content)) + handler-types + (let [types-by-position + (reduce-kv + (fn [result index type] + (let [segment (nth old-content index nil)] + (if (or (nil? segment) (= :close-path (:command segment))) + result + (update result + (path.helpers/segment->point segment) + (fnil conj #{}) + type)))) + {} + handler-types)] + (into {} + (keep (fn [[index segment]] + (when-not (= :close-path (:command segment)) + (let [types (get types-by-position + (path.helpers/segment->point segment))] + (when (= 1 (count types)) + [index (first types)]))))) + (d/enumerate new-content)))))) + +;; Nodes and segments use command indices. Handlers use `[index prefix]`. +;; Selection and hover use grouped index sets: +;; {:nodes #{index} :segments #{index} :handlers #{[index prefix]}} + +(def empty-selection + {:nodes #{} :segments #{} :handlers #{}}) + +(defn node? + "True when the command at the given content index is a selectable node." + [content index] + (and (number? index) + (<= 0 index) + (< index (count content)) + (not= :close-path (:command (nth content index nil))))) + +(defn node-indices + "Indices of every selectable node in the content." + [content] + (into [] + (comp (remove (fn [[_ seg]] (= :close-path (:command seg)))) + (map first)) + (d/enumerate content))) + +(defn node-position + "Position of the node at the given content command index." + [content index] + (path.helpers/segment->point (nth content index))) + +(defn curve-node? + "True when the node at `index` has a visible curve handler." + [content index] + (when (node? content index) + (let [node (node-position content index) + incoming (when (= :curve-to (:command (nth content index nil))) + (path/get-handler-point content index :c2)) + outgoing-index (inc index) + outgoing (when (= :curve-to (:command (nth content outgoing-index nil))) + (path/get-handler-point content outgoing-index :c1))] + (boolean (some #(and (some? %) (not= node %)) [incoming outgoing]))))) + +(defn node-positions + "Set of positions for the given node indices in the content." + [content indices] + (let [indices (set indices)] + (into #{} + (comp (filter (fn [[index _]] (contains? indices index))) + (map (fn [[_ seg]] (path.helpers/segment->point seg)))) + (d/enumerate content)))) + +(defn nodes-in-rect + "Indices of the nodes whose position falls inside the given rect." + [content rect] + (into #{} + (comp (remove (fn [[_ seg]] (= :close-path (:command seg)))) + (filter (fn [[_ seg]] (gsh/has-point-rect? rect (path.helpers/segment->point seg)))) + (map first)) + (d/enumerate content))) + +(def segment-entries + "Returns selectable path segments." + path/segment-entries) + +(defn segment-node-indices + "Unique endpoint-node indices for the selected segment command indices." + [content segment-indices] + (let [segment-indices (set segment-indices)] + (into #{} + (comp (filter #(contains? segment-indices (:index %))) + (mapcat (juxt :from-index :to-index)) + (remove nil?)) + (segment-entries content)))) + +(defn check-enabled + "Returns path actions enabled for selected node indices." + [content selected-nodes] + (when content + (let [selected-nodes (into #{} (filter #(node? content %)) selected-nodes) + selected-segments (filter (fn [{:keys [from-index to-index]}] + (and (contains? selected-nodes from-index) + (contains? selected-nodes to-index))) + (segment-entries content)) + num-segments (count selected-segments) + num-nodes (count selected-nodes) + nodes-selected? (seq selected-nodes) + segments-selected? (seq selected-segments) + max-segments (/ (* num-nodes (dec num-nodes)) 2) + curves-selected? (some #(curve-node? content %) selected-nodes) + corners-selected? (some #(not (curve-node? content %)) selected-nodes)] + {:make-corner (and nodes-selected? curves-selected?) + :make-curve (and nodes-selected? corners-selected?) + :merge-nodes (and nodes-selected? (>= num-nodes 2)) + :join-nodes (and nodes-selected? (>= num-nodes 2) (< num-segments max-segments)) + :separate-nodes (or segments-selected? (= num-nodes 1))}))) + +(defn selected-node-indices + "Returns selected nodes plus endpoints of selected segments." + [content selection] + (into (get selection :nodes #{}) + (segment-node-indices content (get selection :segments #{})))) + +(defn selection-coordinate-rect + "Returns the bounds of selected segments, nodes, and handlers." + [content selection] + (let [segments (get selection :segments #{}) + node-indices (selected-node-indices content selection) + handlers (get selection :handlers #{}) + segment-rect (when (seq segments) + (path/calc-selrect + (path/extract-content content {:segments segments}))) + point-rect (grc/points->rect + (into (node-positions content node-indices) + (keep (fn [[index prefix]] + (path/get-handler-point content index prefix))) + handlers))] + (grc/join-rects (keep identity [segment-rect point-rect])))) + +(defn handler-target-nodes + "Returns nodes targeted by the current node and handler selection." + [content selection] + (into (selected-node-indices content selection) + (map (fn [[idx prefix]] (handler-node-index idx prefix))) + (get selection :handlers #{}))) + +(defn handler-selection-state + "Returns targeted curve nodes and their shared handler mode." + [content handler-types target-nodes] + (let [curve-nodes (into #{} (filter #(curve-node? content %)) target-nodes) + modes (into #{} + (map (fn [index] + (or (get handler-types index) + (derive-handler-type content index)))) + curve-nodes)] + {:nodes curve-nodes + :active-type (cond + (empty? modes) nil + (= 1 (count modes)) (first modes) + :else :mixed)})) + +(defn handler-trigger-action + "Returns the handler menu action for the active mode." + [active-type] + (if (= active-type :mixed) :open :select)) + +(def segment-insert-threshold + "Maximum screen distance for midpoint insertion." + 12) + +(defn segment-mid-point + "Returns a segment's arc-length midpoint with split metadata." + [{:keys [from to segment] :as entry}] + (let [curve (path.helpers/entry->bezier entry) + t (if (= :line-to (:command segment)) + 0.5 + (path.helpers/curve-arc-length-t curve))] + (with-meta (path.helpers/curve-values curve t) + {:from-p from :to-p to :t t}))) + +(defn insertion-mid-points + "Precomputes segment midpoint insertion candidates." + [content] + (into [] + (comp (remove #(= :close-path (:command (:segment %)))) + (map segment-mid-point)) + (segment-entries content))) + +(defn- closest-insertion-mid-point + [mid-points position threshold] + (some->> mid-points + (reduce + (fn [closest mid-point] + (let [distance (gpt/distance position mid-point)] + (if (and (<= distance threshold) + (or (nil? closest) + (< distance (first closest)))) + [distance mid-point] + closest))) + nil) + second)) + +(defn insertion-point + "Returns the on-path point a nearby click would insert, with split metadata." + ([content position threshold anywhere?] + (insertion-point content position threshold anywhere? nil)) + ([content position threshold anywhere? mid-points] + (if anywhere? + (let [point (path/closest-point content position 0.01)] + (when (and (some? point) (<= (gpt/distance position point) threshold)) + point)) + (closest-insertion-mid-point + (or mid-points (insertion-mid-points content)) position threshold)))) + +(defn- segment-lines + [{:keys [from to segment]}] + (if (= :curve-to (:command segment)) + (path.helpers/curve->lines from + to + (path/get-handler segment :c1) + (path/get-handler segment :c2)) + [[from to]])) + +(defn segments-in-rect + "Returns segments that cross or fall inside `rect`." + [content rect] + (let [rect-lines (gsi/points->lines (grc/rect->points rect))] + (into #{} + (comp + (filter + (fn [entry] + (let [lines (segment-lines entry)] + (or (some (fn [[from to]] + (or (grc/contains-point? rect from) + (grc/contains-point? rect to))) + lines) + (gsi/intersects-lines? rect-lines lines))))) + (map :index)) + (segment-entries content)))) + +(defn handler-entries + "Visible path handlers as `{:identity [index prefix] :point p}` entries." + [content] + (into [] + (comp + (mapcat + (fn [[index segment]] + (when (= :curve-to (:command segment)) + (keep + (fn [prefix] + (let [handler (path/get-handler-point content index prefix) + node (path/handler->node content index prefix)] + (when (and handler (not= handler node)) + {:identity [index prefix] + :point handler}))) + [:c1 :c2]))))) + (d/enumerate content))) + +(defn handlers-in-rect + "Identities of visible path handlers whose control point is inside `rect`." + [content rect] + (into #{} + (comp (filter #(grc/contains-point? rect (:point %))) + (map :identity)) + (handler-entries content))) + +(defn remap-selected-nodes + "Remaps selected nodes by position after structural changes." + [selected-nodes old-content new-content] + (if (empty? selected-nodes) + selected-nodes + (let [positions (node-positions old-content selected-nodes)] + (into #{} + (comp (remove (fn [[_ seg]] (= :close-path (:command seg)))) + (filter (fn [[_ seg]] (contains? positions (path.helpers/segment->point seg)))) + (map first)) + (d/enumerate new-content))))) + +(defn- fragment-covered-nodes + "Returns nodes already included in a duplicated segment fragment." + [content {:keys [nodes segments]}] + (let [nodes (or nodes #{}) + segments (or segments #{})] + (into #{} + (comp (filter (fn [{:keys [index from-index to-index]}] + (or (contains? segments index) + (and (contains? nodes from-index) + (contains? nodes to-index))))) + (mapcat (juxt :from-index :to-index))) + (segment-entries content)))) + +(defn duplicate-selection-content + "Duplicates selected nodes and segments for splicing as new subpaths." + [content selection offset] + (let [fragment (path/extract-content content selection) + fragment (cond-> fragment + (and (seq fragment) (some? offset)) + (path/move-content offset)) + fragment (vec fragment) + covered (fragment-covered-nodes content selection) + free (sort (remove covered (get selection :nodes #{})))] + (reduce (fn [{:keys [sub selected]} node-index] + (if-let [{ext :content ext-selected :selected} + (path/duplicate-node-content content node-index offset)] + (let [start (count sub)] + {:sub (into sub ext) + :selected (into selected (map #(+ start %)) ext-selected)}) + {:sub sub :selected selected})) + {:sub fragment :selected (set (node-indices fragment))} + free))) + +(defn remap-selection + "Remaps a grouped selection after path content changes." + [selection old-content new-content] + (let [selection (or selection empty-selection)] + (if (= (count old-content) (count new-content)) + (-> selection + (update :handlers + (fn [handlers] + (into #{} + (filter (fn [[index _]] + (= :curve-to (:command (nth new-content index nil))))) + handlers))) + ;; Drop indices that became subpath breaks. + (update :segments + (fn [segments] + (into #{} + (remove (fn [index] + (= :move-to (:command (nth new-content index nil))))) + segments)))) + (assoc empty-selection + :nodes (remap-selected-nodes (get selection :nodes #{}) + old-content + new-content))))) diff --git a/frontend/src/app/main/data/workspace/path/selection.cljs b/frontend/src/app/main/data/workspace/path/selection.cljs index e4d28c3dd6..998c3d1783 100644 --- a/frontend/src/app/main/data/workspace/path/selection.cljs +++ b/frontend/src/app/main/data/workspace/path/selection.cljs @@ -2,14 +2,13 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.selection (:require [app.common.data.macros :as dm] - [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] - [app.common.geom.shapes :as gsh] + [app.main.data.workspace.path.helpers :as helpers] [app.main.data.workspace.path.state :as st] [app.main.streams :as ms] [app.util.mouse :as mse] @@ -17,84 +16,140 @@ [beicon.v2.operators :as rxo] [potok.v2.core :as ptk])) -(defn path-pointer-enter [position] +(defn path-pointer-enter [index] (ptk/reify ::path-pointer-enter ptk/UpdateEvent (update [_ state] (let [id (st/get-path-id state)] - (update-in state [:workspace-local :edit-path id :hover-points] (fnil conj #{}) position))))) + (update-in state [:workspace-local :edit-path id :hover :nodes] (fnil conj #{}) index))))) -(defn path-pointer-leave [position] +(defn path-pointer-leave [index] (ptk/reify ::path-pointer-leave ptk/UpdateEvent (update [_ state] (let [id (st/get-path-id state)] - (update-in state [:workspace-local :edit-path id :hover-points] disj position))))) + (update-in state [:workspace-local :edit-path id :hover :nodes] disj index))))) (defn path-handler-enter [index prefix] (ptk/reify ::path-handler-enter ptk/UpdateEvent (update [_ state] (let [id (st/get-path-id state)] - (update-in state [:workspace-local :edit-path id :hover-handlers] (fnil conj #{}) [index prefix]))))) + (update-in state [:workspace-local :edit-path id :hover :handlers] (fnil conj #{}) [index prefix]))))) (defn path-handler-leave [index prefix] (ptk/reify ::path-handler-leave ptk/UpdateEvent (update [_ state] (let [id (st/get-path-id state)] - (update-in state [:workspace-local :edit-path id :hover-handlers] disj [index prefix]))))) + (update-in state [:workspace-local :edit-path id :hover :handlers] disj [index prefix]))))) -(defn select-node-area - [initial-set remove?] - (ptk/reify ::select-node-area +(defn path-segment-enter [index] + (ptk/reify ::path-segment-enter ptk/UpdateEvent (update [_ state] - (let [selrect (dm/get-in state [:workspace-local :selrect]) - id (dm/get-in state [:workspace-local :edition]) - content (st/get-path state :content) + (let [id (st/get-path-id state)] + (update-in state [:workspace-local :edit-path id :hover :segments] (fnil conj #{}) index))))) - selected-point? (if (some? selrect) - (partial gsh/has-point-rect? selrect) - (constantly false)) +(defn path-segment-leave [index] + (ptk/reify ::path-segment-leave + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state)] + (update-in state [:workspace-local :edit-path id :hover :segments] disj index))))) - xform (comp (filter #(not (= (:command %) :close-path))) - (map (comp gpt/point :params)) - (filter selected-point?)) - positions (if remove? - (apply disj initial-set (into #{} xform content)) - (into initial-set xform content))] +(defn- select-element + [state type identity shift?] + (let [id (dm/get-in state [:workspace-local :edition]) + selection (or (st/get-selection state id) helpers/empty-selection) + selected (get selection type #{}) + selection (cond + (and shift? (contains? selected identity)) + (update selection type disj identity) - (cond-> state - (some? id) - (assoc-in [:workspace-local :edit-path id :selected-points] positions)))))) + shift? + (update selection type (fnil conj #{}) identity) -(defn select-node [position shift?] + :else + (assoc helpers/empty-selection type #{identity}))] + (cond-> state + (some? id) + (assoc-in [:workspace-local :edit-path id :selection] selection)))) + +(defn select-node [index shift?] (ptk/reify ::select-node ptk/UpdateEvent (update [_ state] - (let [id (dm/get-in state [:workspace-local :edition]) - selected-points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{}) - selected-points (cond - (and shift? (contains? selected-points position)) - (disj selected-points position) + (select-element state :nodes index shift?)))) - shift? - (conj selected-points position) +(defn select-segment [index shift?] + (ptk/reify ::select-segment + ptk/UpdateEvent + (update [_ state] + (select-element state :segments index shift?)))) - :else - #{position})] - (cond-> state - (some? id) - (assoc-in [:workspace-local :edit-path id :selected-points] selected-points)))))) +(defn select-handler [index prefix shift?] + (ptk/reify ::select-handler + ptk/UpdateEvent + (update [_ state] + (select-element state :handlers [index prefix] shift?)))) + +(defn- update-area-set + [initial-set in-rect remove?] + (if remove? + (apply disj initial-set in-rect) + (into initial-set in-rect))) + +(defn select-path-area + [rect initial-selection remove?] + (ptk/reify ::select-path-area + ptk/UpdateEvent + (update [_ state] + (if-not (grc/rect? rect) + state + (let [id (dm/get-in state [:workspace-local :edition]) + content (st/get-path state :content) + + ;; Marquee priority is nodes, segments, then handlers. + nodes (helpers/nodes-in-rect content rect) + segments (if (empty? nodes) + (helpers/segments-in-rect content rect) + #{}) + handlers (if (and (empty? nodes) (empty? segments)) + (helpers/handlers-in-rect content rect) + #{}) + in-rect {:nodes nodes + :segments segments + :handlers handlers} + selection + (reduce-kv + (fn [selection type identities] + (assoc selection type + (update-area-set (get initial-selection type #{}) + identities + remove?))) + helpers/empty-selection + in-rect)] + (cond-> state + (some? id) + (assoc-in [:workspace-local :edit-path id :selection] selection))))))) (defn deselect-all [] (ptk/reify ::deselect-all ptk/UpdateEvent (update [_ state] (let [id (st/get-path-id state)] - (-> state - (assoc-in [:workspace-local :edit-path id :selected-points] #{})))))) + (assoc-in state [:workspace-local :edit-path id :selection] helpers/empty-selection))))) + +(defn select-all-nodes [] + (ptk/reify ::select-all-nodes + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selection (assoc helpers/empty-selection + :nodes (into #{} (helpers/node-indices content)))] + (assoc-in state [:workspace-local :edit-path id :selection] selection))))) (defn update-area-selection [rect] @@ -123,10 +178,10 @@ stopper (mse/drag-stopper stream) from-p @ms/mouse-position - initial-set + initial-selection (if (or append? remove?) - (dm/get-in state [:workspace-local :edit-path id :selected-points] #{}) - #{}) + (or (st/get-selection state id) helpers/empty-selection) + helpers/empty-selection) selrect-stream (->> ms/mouse-position @@ -141,20 +196,11 @@ (rx/merge (->> selrect-stream (rx/map update-area-selection)) + ;; Limit path hit-testing to once per animation frame. (->> selrect-stream - (rx/buffer-time 100) + (rx/buffer-time 16) (rx/map last) + (rx/filter some?) (rx/pipe (rxo/distinct-contiguous)) - (rx/map #(select-node-area initial-set remove?)))) + (rx/map #(select-path-area % initial-selection remove?)))) (rx/of (clear-area-selection)))))))) - -(defn update-selection - [point-change] - (ptk/reify ::update-selection - ptk/UpdateEvent - (update [_ state] - (let [id (st/get-path-id state) - selected-points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{}) - selected-points (into #{} (map point-change) selected-points)] - (-> state - (assoc-in [:workspace-local :edit-path id :selected-points] selected-points)))))) diff --git a/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs b/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs index 6c55397514..7ea9afe315 100644 --- a/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs +++ b/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs @@ -2,13 +2,14 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.shapes-to-path (:require [app.common.data :as d] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cph] + [app.common.geom.matrix :as gmt] [app.common.geom.shapes :as gsh] [app.common.types.container :as ctn] [app.common.types.path :as path] @@ -28,6 +29,18 @@ :rx :ry :r1 :r2 :r3 :r4 :metadata]) +(defn- flatten-path + "Resets a path to axis-aligned geometry." + [shape] + (-> shape + (assoc :rotation 0 + :flip-x false + :flip-y false + :transform (gmt/matrix) + :transform-inverse (gmt/matrix)) + (dissoc :selrect :points) + (path/update-geometry))) + (defn convert-selected-to-path ([] (convert-selected-to-path nil)) @@ -53,18 +66,21 @@ (pcb/update-shapes selected (fn [shape] - (let [content (wasm.api/shape-to-path (:id shape))] - (-> shape - (assoc :type :path) - (cond-> (cph/text-shape? shape) - (assoc :fills - (->> (txt/node-seq txt/is-text-node? (:content shape)) - (map :fills) - (first)))) - (cond-> (cph/image-shape? shape) - (assoc :fill-image (get shape :metadata))) - (d/without-keys dissoc-attrs) - (path/update-geometry content))))) + ;; Keep path content in world coordinates. + (if (cph/path-shape? shape) + (flatten-path shape) + (let [content (wasm.api/shape-to-path (:id shape))] + (-> shape + (assoc :type :path) + (cond-> (cph/text-shape? shape) + (assoc :fills + (->> (txt/node-seq txt/is-text-node? (:content shape)) + (map :fills) + (first)))) + (cond-> (cph/image-shape? shape) + (assoc :fill-image (get shape :metadata))) + (d/without-keys dissoc-attrs) + (path/update-geometry content)))))) (pcb/remove-objects children-ids))] (rx/of (dch/commit-changes changes))) diff --git a/frontend/src/app/main/data/workspace/path/shortcuts.cljs b/frontend/src/app/main/data/workspace/path/shortcuts.cljs index fe35b33e40..45bc319e27 100644 --- a/frontend/src/app/main/data/workspace/path/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/path/shortcuts.cljs @@ -2,13 +2,15 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.shortcuts (:require [app.main.data.shortcuts :as ds] [app.main.data.workspace :as dw] [app.main.data.workspace.path :as drp] + [app.main.data.workspace.path.common :as drp.common] + [app.main.data.workspace.path.state :as drp.state] [app.main.store :as st] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) @@ -19,12 +21,24 @@ ;; Shortcuts format https://github.com/ccampbell/mousetrap -(defn esc-pressed [] +(defn esc-pressed + "Maps Escape to finish, cancel, or exit for the current draw state." + [] (ptk/reify ::esc-pressed ptk/WatchEvent - (watch [_ _ _] - ;; Not interrupt when we're editing a path - (rx/of :interrupt)))) + (watch [_ state _] + (let [id (drp.state/get-path-id state) + pending? (some? (get-in state [:workspace-local :edit-path id :last-point])) + edition (get-in state [:workspace-local :edition])] + (cond + (and pending? (nil? edition)) + (rx/of (drp.common/finish-path)) + + pending? + (rx/of (drp.common/cancel-pending-segment)) + + :else + (rx/of :interrupt)))))) (def shortcuts {:move-nodes {:tooltip "M" @@ -37,6 +51,7 @@ :command "p" :subsections [:path-editor] :section [:workspace] + :overwrite true :fn #(st/emit! (drp/change-edit-mode :draw))} :add-node {:tooltip (ds/shift "+") @@ -49,7 +64,13 @@ :command ["del" "backspace"] :subsections [:path-editor] :section [:workspace] - :fn #(st/emit! (drp/remove-node))} + :overwrite true + :fn #(st/emit! (drp/delete-selected))} + + :delete-node-and-segments {:tooltip (ds/shift (ds/supr)) + :command ["shift+del" "shift+backspace"] + :subsections [:path-editor] + :fn #(st/emit! (drp/delete-selected-with-segments))} :merge-nodes {:tooltip (ds/meta "J") :command (ds/c-mod "j") @@ -67,6 +88,7 @@ :command "k" :subsections [:path-editor] :section [:workspace] + :overwrite true :fn #(st/emit! (drp/separate-nodes))} :make-corner {:tooltip "X" @@ -79,6 +101,7 @@ :command "c" :subsections [:path-editor] :section [:workspace] + :overwrite true :fn #(st/emit! (drp/make-curve))} :snap-nodes {:tooltip (ds/meta "'") @@ -88,11 +111,63 @@ :section [:workspace] :fn #(st/emit! (drp/toggle-snap))} + :copy {:tooltip (ds/meta "C") + :command (ds/c-mod "c") + :subsections [:path-editor] + :fn #(st/emit! (drp/copy-selected-nodes))} + + :cut {:tooltip (ds/meta "X") + :command (ds/c-mod "x") + :subsections [:path-editor] + :fn #(st/emit! (drp/cut-selected-nodes))} + + :paste {:tooltip (ds/meta "V") + :command (ds/c-mod "v") + :subsections [:path-editor] + :fn #(st/emit! (drp/paste-nodes))} + + :duplicate {:tooltip (ds/meta "D") + :command (ds/c-mod "d") + :subsections [:path-editor] + :fn #(st/emit! (drp/duplicate-selected))} + + :select-all {:tooltip (ds/meta "A") + :command (ds/c-mod "a") + :subsections [:path-editor] + :fn #(st/emit! (drp/select-all-nodes))} + + :deselect-all {:tooltip (ds/meta (ds/shift "A")) + :command (ds/c-mod "shift+a") + :subsections [:path-editor] + :fn #(st/emit! (drp/deselect-all))} + + :flip-horizontal {:tooltip (ds/shift "H") + :command "shift+h" + :subsections [:path-editor] + :fn #(st/emit! (drp/flip-nodes :horizontal))} + + :flip-vertical {:tooltip (ds/shift "V") + :command "shift+v" + :subsections [:path-editor] + :fn #(st/emit! (drp/flip-nodes :vertical))} + :escape {:tooltip (ds/esc) - :command ["escape" "enter" "v"] + :command ["escape" "v"] :section [:workspace] :fn #(st/emit! (esc-pressed))} + ;; Reuses the `:start-editing` key (instead of adding "enter" to + ;; the `:escape` command above) so that merging this shortcut set + ;; on top of the base workspace shortcuts (see `dsc/push-shortcuts`) + ;; deterministically replaces the workspace's `enter` binding + ;; (which enters path edit mode) instead of both ending up bound + ;; to the same physical key at once. + :start-editing {:tooltip (ds/enter) + :command "enter" + :section [:workspace] + :overwrite true + :fn #(st/emit! (esc-pressed))} + :undo {:tooltip (ds/meta "Z") :command (ds/c-mod "z") :section [:workspace] diff --git a/frontend/src/app/main/data/workspace/path/state.cljs b/frontend/src/app/main/data/workspace/path/state.cljs index 87a0ebe64b..7b1dcbda75 100644 --- a/frontend/src/app/main/data/workspace/path/state.cljs +++ b/frontend/src/app/main/data/workspace/path/state.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.state (:require @@ -10,18 +10,50 @@ [app.common.types.path.shape-to-path :as stp])) (defn get-path-id - "Retrieves the currently editing path id" + "Returns the active path id. + + The drawing copy is preferred because it also exists during initial path + creation, before workspace edition has an id. The edition id is the fallback + while an existing path's drawing copy is being established." [state] - (or (dm/get-in state [:workspace-local :edition]) - (dm/get-in state [:workspace-drawing :object :id]))) + (or (dm/get-in state [:workspace-drawing :object :id]) + (dm/get-in state [:workspace-local :edition]))) + +(defn get-selection + "Returns the grouped selection for the active path or the supplied path id." + ([state] + (get-selection state (get-path-id state))) + ([state id] + (dm/get-in state [:workspace-local :edit-path id :selection]))) + +(defn current-edit-state + ([state] + (current-edit-state (dm/get-in state [:workspace-local :edit-path]) + (dm/get-in state [:workspace-local :edition]))) + ([edit-path id] + (get edit-path id))) + +(defn editing? + ([state] + (some? (current-edit-state state))) + ([edit-path id] + (some? (current-edit-state edit-path id)))) + +(defn drawing? + ([state] + (let [edition (dm/get-in state [:workspace-local :edition]) + edit-path (dm/get-in state [:workspace-local :edit-path])] + (and (nil? edition) + (some? (get edit-path (get-path-id state)))))) + ([edit-state edition drawing-tool drawing-object] + (or (= :draw (:edit-mode edit-state)) + (and (nil? edition) + (= :path (:type drawing-object)) + (not= :curve drawing-tool))))) (defn get-path-location - [state & ks] - (if-let [edit-id (dm/get-in state [:workspace-local :edition])] - (let [page-id (:current-page-id state) - file-id (:current-file-id state)] - (into [:files file-id :data :pages-index page-id :objects edit-id] ks)) - (into [:workspace-drawing :object] ks))) + [_state & ks] + (into [:workspace-drawing :object] ks)) (defn get-path "Retrieves the location of the path object and additionally can pass diff --git a/frontend/src/app/main/data/workspace/path/streams.cljs b/frontend/src/app/main/data/workspace/path/streams.cljs index 530cb6a977..94b5ef4a41 100644 --- a/frontend/src/app/main/data/workspace/path/streams.cljs +++ b/frontend/src/app/main/data/workspace/path/streams.cljs @@ -2,13 +2,14 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.streams (:require [app.common.data.macros :as dm] [app.common.geom.point :as gpt] [app.common.types.path :as path] + [app.main.data.workspace.edition :as-alias dwe] [app.main.data.workspace.path.state :as pst] [app.main.snap :as snap] [app.main.store :as st] @@ -20,24 +21,30 @@ (defonce drag-threshold 5) +(def ^:private half-pixel-snap-zoom + "Zoom threshold for half-pixel snapping." + 3) + (defn dragging? [start zoom] (fn [current] (>= (gpt/distance start current) (/ drag-threshold zoom)))) -(defn finish-edition? [event] - (= (ptk/type event) :app.main.data.workspace.common/clear-edition-mode)) +(defn finish-edition? + "True for the path edition stop event." + [event] + (= (ptk/type event) ::dwe/clear-edition-mode)) (defn to-pixel-snap [position] (let [layout (get @st/state :workspace-layout) - snap-pixel? (contains? layout :snap-pixel-grid)] + snap-pixel? (contains? layout :snap-pixel-grid) + zoom (get-in @st/state [:workspace-local :zoom] 1)] (cond (or (not snap-pixel?) (not (gpt/point? position))) position - :else - (gpt/round position)))) + (gpt/round-step position (if (> zoom half-pixel-snap-zoom) 0.5 1))))) (defn drag-stream ([to-stream] @@ -79,13 +86,71 @@ (-> (l/derived get-snap st/state) (rx/from-atom {:emit-current-value? true})))) +(def ^:private node-merge-snap-distance + "Maximum screen distance for node merge snapping." + 10) + +(def ^:private neighboring-cell-offsets + [[-1 -1] [-1 0] [-1 1] + [0 -1] [0 0] [0 1] + [1 -1] [1 0] [1 1]]) + +(defn- point-cell + [point cell-size] + [(js/Math.floor (/ (:x point) cell-size)) + (js/Math.floor (/ (:y point) cell-size))]) + +(defn make-node-merge-snap + "Builds a stationary-node index and returns its merge snap function." + [start-point selected-points points max-distance] + (let [selected-points (set selected-points) + point-index (reduce + (fn [index point] + (if (contains? selected-points point) + index + (update index (point-cell point max-distance) (fnil conj []) point))) + {} + points) + closest-target (fn [closest moved-point] + (let [[cell-x cell-y] (point-cell moved-point max-distance)] + (reduce + (fn [closest [offset-x offset-y]] + (reduce + (fn [closest target] + (let [distance (gpt/distance moved-point target)] + (if (and (<= distance max-distance) + (or (nil? closest) + (< distance (first closest)))) + [distance (gpt/subtract target moved-point)] + closest))) + closest + (get point-index [(+ cell-x offset-x) (+ cell-y offset-y)] []))) + closest + neighboring-cell-offsets)))] + (fn [position] + (let [delta (gpt/subtract position start-point) + closest (reduce + (fn [closest selected-point] + (closest-target closest (gpt/add selected-point delta))) + nil + selected-points)] + (when (some? closest) + (gpt/add position (second closest))))))) + (defn move-points-stream [start-point selected-points points] (let [zoom (get-in @st/state [:workspace-local :zoom] 1) + snap-pixel? (contains? (get @st/state :workspace-layout) :snap-pixel-grid) ranges (snap/create-ranges points selected-points) d-pos (/ snap/snap-path-accuracy zoom) + ;; Build the merge index once per pixel-snapped gesture. + merge-distance (/ node-merge-snap-distance zoom) + node-merge-snap (when snap-pixel? + (make-node-merge-snap + start-point selected-points points merge-distance)) + check-path-snap (fn [[position snap-toggled]] (if snap-toggled @@ -93,16 +158,23 @@ moved-points (->> selected-points (mapv #(gpt/add % delta))) snap (snap/get-snap-delta moved-points ranges d-pos)] (gpt/add position snap)) + position)) + + ;; Node merge snapping takes priority over the pixel grid. + snap-position + (fn [[position snap-toggled]] + (if (gpt/point? position) + (or (when node-merge-snap + (node-merge-snap position)) + (check-path-snap [(to-pixel-snap position) snap-toggled])) position))] (->> ms/mouse-position - (rx/map to-pixel-snap) (rx/with-latest-from (snap-toggled-stream)) - (rx/map check-path-snap) - (rx/with-latest-from - (fn [position shift? alt?] - (assoc position :shift? shift? :alt? alt?)) - ms/mouse-position-shift - ms/mouse-position-alt)))) + (rx/map snap-position) + ;; Apply keyboard modifiers without waiting for pointer movement. + (rx/combine-latest-with ms/keyboard-shift ms/keyboard-alt) + (rx/map (fn [[position shift? alt?]] + (assoc position :shift? shift? :alt? alt?)))))) (defn get-angle [node handler opposite] (when (and (some? node) (some? handler) (some? opposite)) @@ -144,13 +216,13 @@ (merge position (gpt/add position snap))))) position))] + ;; Keep handler movement off the pixel grid. (->> ms/mouse-position - (rx/map to-pixel-snap) - (rx/with-latest-from - (fn [position shift? alt?] - (assoc position :shift? shift? :alt? alt?)) - ms/mouse-position-shift - ms/mouse-position-alt) + (rx/filter gpt/point?) + ;; Apply keyboard modifiers without waiting for pointer movement. + (rx/combine-latest-with ms/keyboard-shift ms/keyboard-alt ms/keyboard-mod) + (rx/map (fn [[position shift? alt? mod?]] + (assoc position :shift? shift? :alt? alt? :mod? mod?))) (rx/with-latest-from (snap-toggled-stream)) (rx/map check-path-snap)))) @@ -171,6 +243,8 @@ (rx/map snap/create-ranges))] (->> ms/mouse-position + ;; The subject can hold nil until the pointer enters the viewport + (rx/filter gpt/point?) (rx/map to-pixel-snap) (rx/with-latest-from ranges-stream (snap-toggled-stream)) (rx/map (fn [[position ranges snap-toggled]] @@ -178,8 +252,7 @@ (let [snap (snap/get-snap-delta [position] ranges d-pos)] (gpt/add position snap)) position))) - (rx/with-latest-from - (fn [position shift? alt?] - (assoc position :shift? shift? :alt? alt?)) - ms/mouse-position-shift - ms/mouse-position-alt)))) + ;; Apply Shift without waiting for pointer movement. + (rx/combine-latest-with ms/keyboard-shift ms/keyboard-alt) + (rx/map (fn [[position shift? alt?]] + (assoc position :shift? shift? :alt? alt?)))))) diff --git a/frontend/src/app/main/data/workspace/path/tools.cljs b/frontend/src/app/main/data/workspace/path/tools.cljs index 56a752df03..271634bc7a 100644 --- a/frontend/src/app/main/data/workspace/path/tools.cljs +++ b/frontend/src/app/main/data/workspace/path/tools.cljs @@ -2,54 +2,60 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.tools (:require + [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.geom.point :as gpt] [app.common.types.path :as path] - [app.main.data.changes :as dch] - [app.main.data.helpers :as dsh] [app.main.data.workspace.edition :as dwe] - [app.main.data.workspace.path.changes :as changes] + [app.main.data.workspace.path.helpers :as helpers] [app.main.data.workspace.path.state :as st] - [app.main.data.workspace.shapes :as dwsh] + [app.main.store :as store] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) (defn process-path-tool - "Generic function that executes path transformations with the content and selected nodes" + "Runs a position-based path tool and remaps the selection." ([tool-fn] (process-path-tool nil tool-fn)) ([points tool-fn] (ptk/reify ::process-path-tool - ptk/WatchEvent - (watch [it state _] - (let [page-id (get state :current-page-id) - objects (dsh/lookup-page-objects state page-id) + ptk/UpdateEvent + (update [_ state] + (let [shape (st/get-path state) + id (st/get-path-id state) - shape (st/get-path state) - id (st/get-path-id state) + old-content (:content shape) - selected-points - (dm/get-in state [:workspace-local :edit-path id :selected-points] #{}) + ;; Segment selections include their endpoint nodes. + selected-nodes + (helpers/selected-node-indices + old-content + (st/get-selection state id)) points - (or points selected-points)] + (or points (helpers/node-positions old-content selected-nodes))] - (when (and (seq points) (some? shape)) + (if (and (seq points) (some? shape)) (let [new-content - (-> (tool-fn (:content shape) points) - (path/close-subpaths)) + (-> (tool-fn old-content points) + (path/close-subpaths))] + (-> (cond-> (st/set-content state new-content) + (seq new-content) + (update-in (st/get-path-location state) path/update-geometry)) + (update-in [:workspace-local :edit-path id :selection] + #(helpers/remap-selection % old-content new-content)) + (update-in [:workspace-local :edit-path id :handler-types] + #(helpers/remap-handler-types % old-content new-content)))) + state))) - changes - (changes/generate-path-changes it objects page-id shape (:content shape) new-content)] - - (rx/concat - (rx/of (dwsh/update-shapes [id] path/convert-to-path) - (dch/commit-changes changes)) - (when (empty? new-content) - (rx/of (dwe/clear-edition-mode))))))))))) + ptk/WatchEvent + (watch [_ state _] + (when (empty? (st/get-path state :content)) + (rx/of (dwe/clear-edition-mode))))))) (defn make-corner ([] @@ -58,7 +64,9 @@ (process-path-tool (when point #{point}) (fn [content points] - (reduce path/make-corner-point content points))))) + (->> points + (filter #(path/is-curve-point? content %)) + (reduce path/make-corner-point content)))))) (defn make-curve ([] @@ -67,13 +75,154 @@ (process-path-tool (when point #{point}) (fn [content points] - (reduce path/make-curve-point content points))))) + (->> points + (remove #(path/is-curve-point? content %)) + (reduce path/make-curve-point content)))))) + +(defn- apply-handler-type-modifiers + "Returns modifiers that reshape a node's handlers to `type`." + [content node-index type] + (if-let [[idx prefix] (helpers/node-primary-handler content node-index)] + (case type + :mirror (helpers/move-handler-modifiers content idx prefix true true true 0 0) + :aligned (helpers/align-handler-modifiers content idx prefix 0 0) + {}) + {})) + +(defn set-handler-type + "Sets and stores the handler behavior of selected nodes." + [type] + (ptk/reify ::set-handler-type + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selection (st/get-selection state id) + nodes (helpers/handler-target-nodes content selection)] + (if (and (some? content) (seq nodes)) + (let [modifiers (reduce (fn [acc node-index] + (d/deep-merge acc (apply-handler-type-modifiers content node-index type))) + {} nodes) + new-content (path/apply-content-modifiers content modifiers)] + (-> (st/set-content state new-content) + (update-in (st/get-path-location state) path/update-geometry) + (update-in [:workspace-local :edit-path id :handler-types] + (fn [ht] (reduce #(assoc %1 %2 type) (or ht {}) nodes))))) + state))))) (defn add-node [] (process-path-tool (fn [content points] (path/split-segments content points 0.5)))) -(defn remove-node [] - (process-path-tool path/remove-nodes)) +(defn remove-node + "Removes nodes and heals the gap with a fitted curve." + ([] + (process-path-tool path/remove-nodes)) + ([point] + (process-path-tool #{point} path/remove-nodes))) + +(defn toggle-node-curve + "Toggles a node between a corner and a curve." + [index] + (ptk/reify ::toggle-node-curve + ptk/WatchEvent + (watch [_ state _] + (let [content (st/get-path state :content)] + (when (and (some? content) + (< index (count content)) + (helpers/node? content index)) + (let [point (helpers/node-position content index)] + (rx/of (if (path/is-curve-point? content point) + (make-corner point) + (make-curve point))))))))) + +(defn- update-path-content + "Updates path content, geometry, selection, and handler types." + [state new-content] + (let [id (st/get-path-id state) + old-content (st/get-path state :content)] + (-> (cond-> (st/set-content state new-content) + (seq new-content) + (update-in (st/get-path-location state) path/update-geometry)) + (update-in [:workspace-local :edit-path id :selection] + #(helpers/remap-selection % old-content new-content)) + (update-in [:workspace-local :edit-path id :handler-types] + #(helpers/remap-handler-types % old-content new-content))))) + +(defn remove-segments + "Removes segments and opens the path at their endpoints." + [indices] + (ptk/reify ::remove-segments + ptk/UpdateEvent + (update [_ state] + (let [content (st/get-path state :content)] + (if (and (some? content) (seq indices)) + (update-path-content state (path/remove-segments content indices)) + state))) + + ptk/WatchEvent + (watch [_ state _] + (when (empty? (st/get-path state :content)) + (rx/of (dwe/clear-edition-mode)))))) + +(defn remove-segment + [index] + (remove-segments #{index})) + +(defn remove-node-with-segments + "Removes a node and its incident segments without healing the gap." + [index] + (ptk/reify ::remove-node-with-segments + ptk/WatchEvent + (watch [_ state _] + (let [content (st/get-path state :content) + incident (into #{} + (comp (filter #(or (= index (:to-index %)) + (= index (:from-index %)))) + (map :index)) + (helpers/segment-entries content))] + (when (seq incident) + (rx/of (remove-segments incident))))))) + +(defn delete-selected-with-segments + "Removes selected nodes and their incident segments without healing." + [] + (ptk/reify ::delete-selected-with-segments + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selected (helpers/selected-node-indices + content + (st/get-selection state id)) + incident (into #{} + (comp (filter #(or (contains? selected (:to-index %)) + (contains? selected (:from-index %)))) + (map :index)) + (helpers/segment-entries content))] + (when (seq incident) + (rx/of (remove-segments incident))))))) + +(defn toggle-segment-curve + "Toggles a segment between a line and a curve." + [index] + (ptk/reify ::toggle-segment-curve + ptk/UpdateEvent + (update [_ state] + (let [content (st/get-path state :content)] + (if (some? content) + (update-path-content state (path/toggle-segment-curve content index)) + state))))) + +(defn remove-handler + "Collapses one handler onto its node." + [index prefix] + (ptk/reify ::remove-handler + ptk/UpdateEvent + (update [_ state] + (let [content (st/get-path state :content)] + (if (some? content) + (update-path-content state (path/collapse-handler content index prefix)) + state))))) (defn merge-nodes [] (process-path-tool path/merge-nodes)) @@ -81,8 +230,157 @@ (defn join-nodes [] (process-path-tool path/join-nodes)) +(def ^:private separate-node-screen-offset + "Screen offset between separated node ends." + 8) + (defn separate-nodes [] - (process-path-tool path/separate-nodes)) + ;; Keep the visible gap stable across zoom levels. + (let [zoom (get-in @store/state [:workspace-local :zoom] 1) + step (/ separate-node-screen-offset zoom) + offset (gpt/point step step)] + (process-path-tool + (fn [content points] + (path/separate-nodes content points offset))))) + +(defn delete-selected + "Heals selected nodes or opens selected segments." + [] + (ptk/reify ::delete-selected + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selection (st/get-selection state id) + nodes (get selection :nodes #{}) + segments (get selection :segments #{})] + (rx/of + (cond + ;; Node selection takes priority in mixed selections. + (seq nodes) + (process-path-tool (helpers/node-positions content nodes) path/remove-nodes) + + ;; Segment-only selection opens the path. + (seq segments) + (separate-nodes) + + :else + (remove-node))))))) + +(defn flip-nodes + "Flips selected nodes, or the whole path when none are selected." + [axis] + (ptk/reify ::flip-nodes + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selected (helpers/selected-node-indices + content + (st/get-selection state id)) + indices (if (seq selected) + selected + (helpers/node-indices content)) + content (path/flip-content content indices axis)] + (-> (st/set-content state content) + (update-in (st/get-path-location state) path/update-geometry)))))) + +(defn align-nodes + "Aligns selected nodes and their handles within their bounds." + [axis] + (ptk/reify ::align-nodes + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selected (get (st/get-selection state id) :nodes #{}) + content (path/align-content content selected axis)] + (-> (st/set-content state content) + (update-in (st/get-path-location state) path/update-geometry)))))) + +(defn distribute-nodes + "Distributes selected nodes evenly along `axis`." + [axis] + (ptk/reify ::distribute-nodes + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selected (get (st/get-selection state id) :nodes #{}) + content (path/distribute-content content selected axis)] + (-> (st/set-content state content) + (update-in (st/get-path-location state) path/update-geometry)))))) + +(defn- axis-point + "Copy of `p` with `axis` (`:x`/`:y`) replaced by `value`." + [p axis value] + (if (= axis :x) (gpt/point value (:y p)) (gpt/point (:x p) value))) + +(defn- handler-target-points + "Returns handler targets for an absolute coordinate edit." + [content handlers handler-types axis value] + (reduce + (fn [pts [index prefix]] + (let [hp (path/get-handler-point content index prefix) + hp' (axis-point hp axis value) + node-index (helpers/handler-node-index index prefix) + mode (or (get handler-types node-index) + (helpers/derive-handler-type content node-index)) + [op-idx op-prefix] (path/opposite-index content index prefix) + pts (assoc pts [index prefix] hp')] + (if (and (contains? #{:mirror :aligned} mode) (some? op-idx)) + (let [node (path/handler->node content index prefix) + opp (path/get-handler-point content op-idx op-prefix) + opp' (helpers/opposite-handler-target node hp' opp mode)] + (assoc pts [op-idx op-prefix] opp')) + pts))) + {} + handlers)) + +(defn- translated-handler-target-points + "Returns standalone handler targets for a group translation." + [content handlers node-indices delta] + (into {} + (comp + (remove (fn [[index prefix]] + (contains? node-indices + (helpers/handler-node-index index prefix)))) + (keep (fn [[index prefix :as identity]] + (when-let [point (path/get-handler-point content index prefix)] + [identity (gpt/add point delta)])))) + handlers)) + +(defn set-selection-coordinate + "Sets one coordinate of the current path selection." + [axis value] + (ptk/reify ::set-selection-coordinate + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selection (st/get-selection state id) + htypes (dm/get-in state [:workspace-local :edit-path id :handler-types]) + segments (get selection :segments #{}) + handlers (get selection :handlers #{}) + node-idx (helpers/selected-node-indices content selection) + + new-content + (if (seq segments) + ;; Translate segment selections as one group. + (let [rect (helpers/selection-coordinate-rect content selection) + cur (if (= axis :x) (dm/get-prop rect :x) (dm/get-prop rect :y)) + delta (axis-point (gpt/point 0 0) axis (- value cur)) + htargets (translated-handler-target-points + content handlers node-idx delta)] + (cond-> (path/translate-selected-nodes content node-idx delta) + (seq htargets) (path/set-handler-points htargets))) + ;; Set node and handler coordinates directly. + (let [pts (handler-target-points content handlers htypes axis value)] + (cond-> content + (seq node-idx) (path/set-nodes-coordinate node-idx axis value) + (seq pts) (path/set-handler-points pts))))] + (-> (st/set-content state new-content) + (update-in (st/get-path-location state) path/update-geometry)))))) (defn toggle-snap [] (ptk/reify ::toggle-snap diff --git a/frontend/src/app/main/data/workspace/path/undo.cljs b/frontend/src/app/main/data/workspace/path/undo.cljs index 76a9f35f62..cec76924c6 100644 --- a/frontend/src/app/main/data/workspace/path/undo.cljs +++ b/frontend/src/app/main/data/workspace/path/undo.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.undo (:require @@ -12,8 +12,8 @@ [app.main.data.workspace.common :as dwc] [app.main.data.workspace.edition :as-alias dwe] [app.main.data.workspace.pages :as-alias dwpg] - [app.main.data.workspace.path.changes :as changes] [app.main.data.workspace.path.common :as common] + [app.main.data.workspace.path.helpers :as helpers] [app.main.data.workspace.path.state :as st] [app.main.store :as store] [beicon.v2.core :as rx] @@ -28,17 +28,17 @@ [event] (= :app.main.data.workspace.common/redo (ptk/type event))) +;; Undo entries skip the render-only preview. (defn- make-entry [state] (let [id (st/get-path-id state) shape (st/get-path state)] {:content (:content shape) :selrect (:selrect shape) :points (:points shape) - :preview (get-in state [:workspace-local :edit-path id :preview]) :last-point (get-in state [:workspace-local :edit-path id :last-point]) :prev-handler (get-in state [:workspace-local :edit-path id :prev-handler])})) -(defn- load-entry [state {:keys [content selrect points preview last-point prev-handler]}] +(defn- load-entry [state {:keys [content selrect points last-point prev-handler]}] (let [id (st/get-path-id state) old-content (st/get-path state :content)] (-> state @@ -47,11 +47,14 @@ (d/assoc-in-when (st/get-path-location state :points) points) (d/update-in-when [:workspace-local :edit-path id] - assoc - :preview preview - :last-point last-point - :prev-handler prev-handler - :old-content old-content)))) + (fn [edit-state] + ;; Remap the selection to the restored content. + (cond-> (assoc edit-state + :preview nil + :last-point last-point + :prev-handler prev-handler) + (some? content) + (update :selection helpers/remap-selection old-content content))))))) (defn undo-path [] (ptk/reify ::undo-path @@ -72,10 +75,8 @@ (watch [_ state _] (let [id (st/get-path-id state) undo-stack (get-in state [:workspace-local :edit-path id :undo-stack])] - (if (> (:index undo-stack) 0) - (rx/of (changes/save-path-content {:preserve-move-to true})) - (rx/of (changes/save-path-content {:preserve-move-to true}) - (common/finish-path) + (when (zero? (:index undo-stack)) + (rx/of (common/finish-path) (dwc/show-toolbar))))))) (defn redo-path [] @@ -90,11 +91,7 @@ (load-entry entry) (d/assoc-in-when [:workspace-local :edit-path id :undo-stack] - undo-stack)))) - - ptk/WatchEvent - (watch [_ _ _] - (rx/of (changes/save-path-content))))) + undo-stack)))))) (defn merge-head "Joins the head with the previous undo in one. This is done so when the user changes a @@ -171,4 +168,3 @@ (rx/map #(add-undo-entry))) (rx/of (end-path-undo)))))))))) - diff --git a/frontend/src/app/main/data/workspace/reflow.cljs b/frontend/src/app/main/data/workspace/reflow.cljs index 3932aa7ca7..b6326d0e14 100644 --- a/frontend/src/app/main/data/workspace/reflow.cljs +++ b/frontend/src/app/main/data/workspace/reflow.cljs @@ -2,14 +2,16 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.reflow - "Tracks the shape ids that have layout/reflow work in flight, broken down by - the kind of work so we can tell which type of reflow is blocking each shape. + "Tracks the ids that have layout/reflow work in flight, broken down by the + kind of work so we can tell which type of reflow is blocking each id. - Pending work is stored as `{shape-id -> {kind -> #{task-id}}}`. Every producer - opens an exact task with `start!` and closes that same task with `finish!`. + Pending work is stored as `{id -> {kind -> #{task-id}}}`, where ids are page + object ids plus, for `:sync-file`, the id of the file being synced. Every + producer opens an exact task with `start!` and closes that same task with + `finish!`. Tasks belong to a workspace generation, so a delayed completion from a finalized workspace cannot drain work opened after the workspace reloads. @@ -21,8 +23,10 @@ :layout flex/grid layout reflow (shape-layout) :text-resize text geometry resize (wasm-text, texts) :text-measure DOM text measurement (texts) + :text-position DOM text fragment geometry (texts) :text-bridge change awaiting its pipeline (texts) - :font font change measurement (texts)" + :font font change measurement (texts) + :sync-file component/library propagation (libraries)" (:require [beicon.v2.core :as rx] [promesa.core :as p])) @@ -55,22 +59,35 @@ acc ids))) +;; Single-task operations are wrapped as batches before reaching the reducer. (defn- reducer - [acc {:keys [op task ids]}] + [acc {:keys [op tasks ids]}] (case op - :add (add-task acc task) - :remove (remove-task acc task) + :add (reduce add-task acc tasks) + :remove (reduce remove-task acc tasks) :cancel (apply dissoc acc ids) :reset {} acc)) -;; Behaviour subject holding `{shape-id -> {kind -> #{task-id}}}`. -;; It replays its current value synchronously to new subscribers, which gives -;; `wait-for-layout-update` a free fast-path when there is nothing pending. -(defonce ^:private pending-shapes - (let [sub (rx/behavior-subject {})] - (rx/sub! (->> reflow-input (rx/scan reducer {})) sub) - sub)) +;; Holds pending tasks and replays them to new waiters. +;; Reloads rebuild the scan with the latest reducer. +(def ^:private pending-shapes (rx/behavior-subject {})) + +(defonce ^:private pending-subscription (atom nil)) + +(defn- install-pending-subscription! + [] + ;; Settle the old scan before installing the new one. + (swap! workspace-generation inc) + (rx/push! reflow-input {:op :reset}) + (when-let [subscription @pending-subscription] + (rx/dispose! subscription)) + (reset! pending-subscription + (rx/sub! (->> reflow-input (rx/scan reducer {})) + pending-shapes)) + (rx/push! reflow-input {:op :reset})) + +(install-pending-subscription!) (defn task "Creates an opaque task token without opening it." @@ -80,24 +97,42 @@ :kind kind :ids (into #{} ids)}) +(defn- push-tasks! + [op tasks] + ;; Empty and stale tasks must not affect the active workspace. + (let [generation @workspace-generation + tasks (into [] (filter #(and (seq (:ids %)) + (= (:generation %) generation))) + tasks)] + (when (seq tasks) + (rx/push! reflow-input {:op op :tasks tasks})) + tasks)) + +(defn- start-tasks! + "Opens task tokens in one pending-map update." + [tasks] + (push-tasks! :add tasks)) + (defn start! "Opens and returns a task. The one-argument form opens a token created with `task`; the two-argument form creates and opens it in one step." ([task] - (when (and (seq (:ids task)) - (= (:generation task) @workspace-generation)) - (rx/push! reflow-input {:op :add :task task})) + (push-tasks! :add [task]) task) ([kind ids] (start! (task kind ids)))) +(defn finish-tasks! + "Closes task tokens from the active workspace generation in one update." + [tasks] + (push-tasks! :remove tasks) + nil) + (defn finish! "Closes `task` if it belongs to the active workspace generation. Repeated or stale completion is a no-op." - [{:keys [generation ids] :as task}] - (when (and (seq ids) - (= generation @workspace-generation)) - (rx/push! reflow-input {:op :remove :task task}))) + [task] + (finish-tasks! [task])) (defn reset-pending! "Starts a new workspace generation and forgets every task from the old one." @@ -136,59 +171,77 @@ (finish! task) (throw cause))))) -(defn pending-signal - "Emits once any of `kinds` is pending for any of `ids`, then completes. - Emits right away when that work is already in flight." - [ids kinds] - (letfn [(id-pending? [pending id] - (some (partial contains? (get pending id)) kinds)) +(defn bridge-pending + "Keeps each id pending until matching work starts." + [ids target-kinds bridge-kind] + (let [ids (into #{} ids)] + (if (empty? ids) + (rx/empty) + (rx/create + (fn [subs] + ;; Separate tasks let renderer work release each shape independently. + (let [tasks-by-id + (into {} (map (fn [id] [id (task bridge-kind [id])])) ids) - (any-pending? [pending] - (some (partial id-pending? pending) ids))] - (->> pending-shapes - (rx/filter any-pending?) - (rx/take 1)))) + remaining + (atom ids) -;; Ceiling for callers that pass no timeout, so a pipeline that never drains -;; its marks rejects the promise rather than leaving it unsettled. -(def ^:private default-timeout 30000) + release! + (fn [released] + (let [released (into #{} (filter @remaining) released)] + (when (seq released) + (finish-tasks! (map tasks-by-id released)) + (swap! remaining #(apply disj % released)) + (when (empty? @remaining) + (rx/end! subs))))) -(defn wait-for-layout-update - "Returns a JS Promise that resolves when every id in `shape-ids` has drained - from the pending map. A nil `shape-ids` waits for every pending shape; an - empty one has nothing to wait for and resolves right away. The promise is - rejected when `timeout` (ms) elapses first; a nil `timeout` uses - `default-timeout`. + matching-task-ids + (fn [tasks] + (into #{} + (comp + (filter #(contains? target-kinds (:kind %))) + (mapcat :ids) + (filter ids)) + tasks)) + + ;; Listen before opening bridges so synchronous work is not missed. + lifecycle-sub + (rx/sub! + reflow-input + (fn [{:keys [op tasks ids]}] + (case op + :add + (release! (matching-task-ids tasks)) + + :cancel + (release! ids) + + :reset + (release! @remaining) + + nil))) + + _ + (start-tasks! (vals tasks-by-id))] + (fn [] + (rx/dispose! lifecycle-sub) + (when (seq @remaining) + (finish-tasks! (map tasks-by-id @remaining)) + (reset! remaining #{}))))))))) + +(defn settled + "Observable that emits once every id in `ids` has drained from the pending + map, then completes. A nil `ids` waits for every pending id; an empty one has + nothing to wait for. Replays on subscribe, so an already drained map emits + immediately. Callers waiting on one shape pass its whole subtree: reflow work lands either on the shape (a board laying out its children) or on its descendants (a group whose texts are re-measured)." - ([timeout] - (wait-for-layout-update nil timeout)) - ([shape-ids timeout] - (js/Promise. - (fn [resolve reject] - (let [timeout (or timeout default-timeout) - - done? (if (some? shape-ids) - (fn [pending] (not-any? #(contains? pending %) shape-ids)) - empty?) - - settled (->> pending-shapes - (rx/filter done?) - (rx/map (constantly :ok))) - - ;; Race the settle signal against the deadline; the loser is - ;; unsubscribed. `settled` replays on subscribe, so an already - ;; drained map wins even against a 1ms deadline. - source (rx/race (->> (rx/of :timeout) - (rx/delay timeout)) - settled)] - (->> source - (rx/take 1) - (rx/subs! - (fn [value] - (if (= value :timeout) - (reject (js/Error. "waitForLayoutUpdate timeout")) - (resolve))) - reject))))))) + [ids] + (let [done? (if (some? ids) + (fn [pending] (not-any? #(contains? pending %) ids)) + empty?)] + (->> pending-shapes + (rx/filter done?) + (rx/take 1)))) diff --git a/frontend/src/app/main/data/workspace/reflow/signals.cljs b/frontend/src/app/main/data/workspace/reflow/signals.cljs new file mode 100644 index 0000000000..fe92de3ea9 --- /dev/null +++ b/frontend/src/app/main/data/workspace/reflow/signals.cljs @@ -0,0 +1,141 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.main.data.workspace.reflow.signals + "Decides which reflow signals a shape update raises: `:layout/update` for the + shapes whose layout attrs changed, `:text/reflow` for the texts the renderer + has to re-measure. + + Which text attrs matter depends on the renderer: the DOM one measures every + changed text, so its own geometry counts as a change; wasm only resizes + auto-sized texts from their content." + (:require + [app.common.data :as d] + [app.common.data.macros :as dm] + [app.common.files.changes-builder :as pcb] + [app.common.files.helpers :as cfh] + [app.common.math :as mth] + [app.main.features :as features])) + +;; If anything a translation can mutate is added here, drop the +;; `(when-not translation? …)` guard in `update-shapes`. +(def ^:private update-layout-attr? #{:hidden}) + +;; Text attrs that can start async renderer work. +(def ^:private text-reflow-attr? + #{:content :grow-type :x :y :width :height}) + +(def ^:private wasm-text-reflow-attr? + #{:content :grow-type}) + +(def ^:private dom-text-geometry-reflow-attr? + #{:x :y :width :height}) + +(defn- renderer-text-reflow-attr? + [state] + (if (features/active-feature? state "render-wasm/v1") + wasm-text-reflow-attr? + text-reflow-attr?)) + +(defn- reflow-attr? + [state attr] + (or (update-layout-attr? attr) + ((renderer-text-reflow-attr? state) attr))) + +;; Caller metadata can rule out reflow before objects are compared. +(defn- reflow-candidate? + [attr? {:keys [attrs translation? update-layout?] + :or {update-layout? true}}] + (and update-layout? + (not translation?) + (or (nil? attrs) + (some attr? attrs)))) + +(defn- text-reflow-changed? + [state shape changed-shape changed] + ;; Match the DOM renderer's geometry checks. + (let [wasm? (features/active-feature? state "render-wasm/v1") + reflow-attr? (renderer-text-reflow-attr? state)] + (some + (fn [attr] + (and (reflow-attr? attr) + (or wasm? + (not (dom-text-geometry-reflow-attr? attr)) + (not (mth/close? (get shape attr) + (get changed-shape attr)))))) + changed))) + +(defn- async-text-reflow? + "Whether `shape` enters an asynchronous text geometry pipeline. The HTML + renderer measures every changed text; WASM only resizes auto-sized texts. + A grow-type transition is included because `shape` is the value before the + update and may still be fixed." + [state shape changed] + (and (cfh/text-shape? shape) + (or (not (features/active-feature? state "render-wasm/v1")) + (not= :fixed (:grow-type shape)) + (contains? changed :grow-type)))) + +(defn- get-reflow-changes + [state objects changed-objects ids {:keys [attrs] :as props}] + ;; Reuse built objects so update functions only run once. + (let [reflow-attr? (partial reflow-attr? state)] + (when (reflow-candidate? reflow-attr? props) + (into [] + (comp + (map (d/getf objects)) + (keep (fn [shape] + (let [changed-shape (get changed-objects (:id shape)) + changed (pcb/changed-attrs + shape objects (constantly changed-shape) + {:attrs attrs})] + (when (some reflow-attr? changed) + [shape changed-shape changed]))))) + ids)))) + +(defn- get-layout-reflow-ids + [reflow-changes] + (->> reflow-changes + (into [] (comp (filter (fn [[_ _ changed]] (some update-layout-attr? changed))) + (map (comp :id first)))) + (not-empty))) + +(defn- get-text-reflow-ids + [state page-id reflow-changes] + ;; Track measurable texts on the active page. + (when (= page-id (get state :current-page-id)) + (let [edition (dm/get-in state [:workspace-local :edition])] + (->> reflow-changes + (into [] (comp (filter (fn [[shape changed-shape changed]] + (and (async-text-reflow? state shape changed) + (text-reflow-changed? + state shape changed-shape changed)))) + (map (comp :id first)) + (remove #(= % edition)))) + (not-empty))))) + +(defn reflow-ids + "Ids a shape update has to signal: `:layout-ids` for `:layout/update`, + `:text-ids` for `:text/reflow`. Both are nil when nothing changed. + + Both sets come from one comparison pass, so `update-fn` and the attribute + diff only run once per shape." + [state page-id objects changed-objects ids props] + (let [reflow-changes (get-reflow-changes state objects changed-objects ids props)] + {:layout-ids (get-layout-reflow-ids reflow-changes) + :text-ids (get-text-reflow-ids state page-id reflow-changes)})) + +(defn text-reflow-candidate? + "Whether `props` can start renderer text work, judged from the caller metadata + alone. Cheap pre-filter for callers that buffer updates before they have + objects to compare." + [state props] + (reflow-candidate? (renderer-text-reflow-attr? state) props)) + +(defn new-text-reflow? + "Whether a newly added `shape` enters an asynchronous text geometry pipeline." + [state shape] + (async-text-reflow? state shape nil)) diff --git a/frontend/src/app/main/data/workspace/selection.cljs b/frontend/src/app/main/data/workspace/selection.cljs index 36039f230b..aaab6e6802 100644 --- a/frontend/src/app/main/data/workspace/selection.cljs +++ b/frontend/src/app/main/data/workspace/selection.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.selection (:require @@ -29,6 +29,7 @@ [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.viewport-wasm :as dwvw] [app.main.data.workspace.zoom :as dwz] + [app.main.features :as features] [app.main.refs :as refs] [app.main.router :as rt] [app.main.streams :as ms] @@ -452,6 +453,16 @@ (gpt/subtract new-pos pt-obj))))) +(defn- get-new-dom-text-ids + [state changes] + (when-not (features/active-feature? state "render-wasm/v1") + (->> (:redo-changes changes) + (keep (fn [{:keys [type obj]}] + (when (and (= type :add-obj) + (cfh/text-shape? obj)) + (:id obj)))) + (not-empty)))) + (defn duplicate-shapes [ids & {:keys [move-delta? alt-duplication? change-selection? return-ref] :or {move-delta? false alt-duplication? false change-selection? true return-ref nil}}] @@ -493,6 +504,9 @@ (map #(get-in % [:obj :id])) (into (d/ordered-set))) + new-dom-text-ids + (get-new-dom-text-ids state changes) + id-duplicated (first new-ids) frames (into #{} @@ -531,6 +545,11 @@ ;; Warning: This order is important for the focus mode. (->> (rx/of (dwu/start-undo-transaction undo-id) + ;; Track cloned texts before they mount. + (when new-dom-text-ids + (ptk/data-event :text/reflow + {:ids new-dom-text-ids + :page-id (:id page)})) (dch/commit-changes changes) (when change-selection? (select-shapes new-ids)) diff --git a/frontend/src/app/main/data/workspace/shape_layout.cljs b/frontend/src/app/main/data/workspace/shape_layout.cljs index fade07bd2d..9d03bdbec1 100644 --- a/frontend/src/app/main/data/workspace/shape_layout.cljs +++ b/frontend/src/app/main/data/workspace/shape_layout.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.shape-layout (:require @@ -23,6 +23,7 @@ [app.main.data.changes :as dch] [app.main.data.event :as ev] [app.main.data.helpers :as dsh] + [app.main.data.workspace :as-alias dw] [app.main.data.workspace.colors :as cl] [app.main.data.workspace.grid-layout.editor :as dwge] [app.main.data.workspace.modifiers :as dwm] @@ -131,14 +132,14 @@ (->> stream (rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit)) (rx/take 1) - (rx/take-until (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)) + (rx/take-until (rx/filter (ptk/type? ::dw/finalize-workspace) stream)) ;; No events are derived from this (rx/ignore)) (rx/empty))] (cond->> (rx/concat update-positions-stream drain-stream) (d/not-empty? reflow-tasks) - (rx/finalize #(run! wrf/finish! reflow-tasks))))))) + (rx/finalize #(wrf/finish-tasks! reflow-tasks))))))) (defn- without-root-board [ids] diff --git a/frontend/src/app/main/data/workspace/shapes.cljs b/frontend/src/app/main/data/workspace/shapes.cljs index 0b3cfb3f94..2454b537a1 100644 --- a/frontend/src/app/main/data/workspace/shapes.cljs +++ b/frontend/src/app/main/data/workspace/shapes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.shapes (:require @@ -24,34 +24,12 @@ [app.main.data.workspace.collapse :as dwco] [app.main.data.workspace.edition :as dwe] [app.main.data.workspace.reflow :as wrf] + [app.main.data.workspace.reflow.signals :as wrfs] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.undo :as dwu] - [app.main.features :as features] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) -;; If anything a translation can mutate is added here, drop the -;; `(when-not translation? …)` guard in `update-shapes` below. -(def ^:private update-layout-attr? #{:hidden}) - -;; Text attrs whose change makes the DOM text pipeline re-measure the shape. -(def ^:private text-reflow-attr? #{:content :grow-type}) - -(defn- reflow-attr? - [attr] - (or (update-layout-attr? attr) (text-reflow-attr? attr))) - -(defn- async-text-reflow? - "Whether `shape` enters an asynchronous text geometry pipeline. The HTML - renderer measures every changed text; WASM only resizes auto-sized texts. - A grow-type transition is included because `shape` is the value before the - update and may still be fixed." - [state shape changed] - (and (cfh/text-shape? shape) - (or (not (features/active-feature? state "render-wasm/v1")) - (not= :fixed (:grow-type shape)) - (contains? changed :grow-type)))) - (defn- add-undo-group [changes state] (let [undo (:workspace-undo state) @@ -82,15 +60,35 @@ (update [_ state] (assoc state ::update-shapes-buffer false)))) +(defn- get-buffered-text-reflow-event + [state page-id ids] + (when (= page-id (get state :current-page-id)) + ;; Analyze accumulated objects through the same path as immediate updates. + (let [objects (dsh/lookup-page-objects state page-id) + changed-objects (-> (get-in state [::update-shapes-buffer-changes page-id]) + (pcb/lookup-objects)) + {:keys [text-ids]} + (wrfs/reflow-ids state page-id objects changed-objects ids nil)] + (when text-ids + (ptk/data-event :text/reflow {:ids text-ids :page-id page-id}))))) + (defn update-shapes-buffer-commit [] (ptk/reify ::update-shapes-buffer-commit ptk/WatchEvent (watch [_ state _] - (->> (get state ::update-shapes-buffer-changes) - (vals) - (map dch/commit-changes) - (rx/from))))) + (let [text-reflow-events + (->> (get state ::update-shapes-buffer-text-candidates) + (keep (fn [[page-id ids]] + (get-buffered-text-reflow-event state page-id ids)))) + + commits + (->> (get state ::update-shapes-buffer-changes) + (vals) + (map dch/commit-changes))] + ;; Open bridges before commits start rendering. + (rx/concat (rx/from text-reflow-events) + (rx/from commits)))))) ;; Looks for the objects data in the state, if there is an "in progress" ;; update-shapes-buffer will return the objeccts inside the current changes @@ -111,7 +109,8 @@ (update-shapes-buffer ids update-fn nil)) ([ids update-fn {:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id - ignore-touched undo-group with-objects? changed-sub-attr translation?] + ignore-touched undo-group with-objects? changed-sub-attr + translation? skip-component-sync?] :or {reg-objects? false save-undo? true stack-undo? false @@ -126,9 +125,14 @@ (assoc state ::update-shapes-buffer-event cur-event) (let [page-id (or page-id (get state :current-page-id)) - objects (dsh/lookup-page-objects state page-id)] - (-> state + objects (lookup-changed-objects state page-id) + text-ids + (into #{} + (filter #(cfh/text-shape? objects %)) + ids) + state (update-in + state [::update-shapes-buffer-changes page-id] (fn [changes] (-> (or changes @@ -148,7 +152,16 @@ :ignore-touched ignore-touched :with-objects? with-objects?}) (cond-> reg-objects? (pcb/resize-parents ids)) - (pcb/set-translation? translation?)))))))) + (pcb/set-translation? translation?) + (pcb/set-skip-component-sync? skip-component-sync?))))] + ;; Check buffered text candidates when the buffer is committed. + (if (or (empty? text-ids) + (not (wrfs/text-reflow-candidate? state props))) + state + (update-in state + [::update-shapes-buffer-text-candidates page-id] + (fnil into #{}) + text-ids))))) ptk/WatchEvent (watch [_ state stream] @@ -165,6 +178,7 @@ (rx/of #(dissoc % ::update-shapes-buffer-changes + ::update-shapes-buffer-text-candidates ::update-shapes-buffer-event)))) (rx/empty))))))) @@ -175,13 +189,12 @@ {:as props :keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id ignore-touched undo-group with-objects? changed-sub-attr translation? - update-layout?] + skip-component-sync?] :or {reg-objects? false save-undo? true stack-undo? false ignore-touched false - with-objects? false - update-layout? true}}] + with-objects? false}}] (assert (every? uuid? ids) "expect a coll of uuid for `ids`") (assert (fn? update-fn) "the `update-fn` should be a valid function") @@ -197,49 +210,6 @@ objects (dsh/lookup-page-objects state page-id) ids (into [] (filter some?) ids) - ;; Pairs of [shape changed-attrs] for the shapes whose change - ;; matters to a reflow, feeding both id sets below. - xf-reflow - (comp - (map (d/getf objects)) - (keep (fn [shape] - (let [changed (pcb/changed-attrs shape objects update-fn - {:attrs attrs :with-objects? with-objects?})] - (when (some reflow-attr? changed) - [shape changed]))))) - - ;; `changed-attrs` runs `update-fn` in full for every shape, which - ;; can be expensive (e.g. `update-bool-shape` recalculates the whole - ;; boolean path in WASM). Skip the pass entirely when we can prove it - ;; cannot match: when the caller declares `attrs`, `changed-attrs` - ;; filters its result to that set, so if no reflow attr is present - ;; the check is always empty. - reflow-changes - (when-not (or translation? - (not update-layout?) - (and (some? attrs) - (not (some reflow-attr? attrs)))) - (into [] xf-reflow ids)) - - update-layout-ids - (->> reflow-changes - (into [] (comp (filter (fn [[_ changed]] (some update-layout-attr? changed))) - (map (comp :id first)))) - (not-empty)) - - ;; Text shapes the DOM pipeline has to re-measure, narrowed to what - ;; it actually measures: the active page, never the edited shape. - text-reflow-ids - (when (= page-id (get state :current-page-id)) - (let [edition (dm/get-in state [:workspace-local :edition])] - (->> reflow-changes - (into [] (comp (filter (fn [[shape changed]] - (and (async-text-reflow? state shape changed) - (some text-reflow-attr? changed)))) - (map (comp :id first)) - (remove #(= % edition)))) - (not-empty)))) - changes (-> (pcb/empty-changes it page-id) (pcb/set-save-undo? save-undo?) @@ -255,7 +225,14 @@ :translation? translation?}) (cond-> undo-group (pcb/set-undo-group undo-group)) - (pcb/set-translation? translation?)) + (pcb/set-translation? translation?) + (pcb/set-skip-component-sync? skip-component-sync?)) + + changed-objects + (pcb/lookup-objects changes) + + {:keys [layout-ids text-ids]} + (wrfs/reflow-ids state page-id objects changed-objects ids props) changes (add-undo-group changes state)] @@ -264,8 +241,8 @@ ;; Announces the texts still to be re-measured, so a reflow wait ;; covers the render that measures them. Goes before the commit, ;; which is what triggers that render. - (if text-reflow-ids - (rx/of (ptk/data-event :text/reflow {:ids text-reflow-ids :page-id page-id})) + (if text-ids + (rx/of (ptk/data-event :text/reflow {:ids text-ids :page-id page-id})) (rx/empty)) (if (seq (:redo-changes changes)) @@ -274,8 +251,8 @@ (rx/empty)) ;; Update layouts for properties marked - (if update-layout-ids - (rx/of (ptk/data-event :layout/update {:ids update-layout-ids})) + (if layout-ids + (rx/of (ptk/data-event :layout/update {:ids layout-ids})) (rx/empty))))))))) (defn add-shape @@ -321,7 +298,7 @@ (rx/of (dwu/start-undo-transaction undo-id) ;; A new text has no geometry until the pipeline measures it, ;; so it raises the same signal an edit does. - (when (async-text-reflow? state shape nil) + (when (wrfs/new-text-reflow? state shape) (ptk/data-event :text/reflow {:ids [(:id shape)] :page-id page-id})) (dch/commit-changes changes) (when-not no-update-layout? diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index 192258f0d7..5c042575a4 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.shortcuts (:require @@ -110,6 +110,7 @@ :command (ds/c-mod "v") :subsections [:edit] :section [:workspace] + :customizable false :fn (constantly nil)} :paste-replace {:tooltip (ds/meta (ds/shift "V")) diff --git a/frontend/src/app/main/data/workspace/specialized_panel.cljs b/frontend/src/app/main/data/workspace/specialized_panel.cljs index f3dd744c21..f32ed5f751 100644 --- a/frontend/src/app/main/data/workspace/specialized_panel.cljs +++ b/frontend/src/app/main/data/workspace/specialized_panel.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.specialized-panel (:require diff --git a/frontend/src/app/main/data/workspace/svg_upload.cljs b/frontend/src/app/main/data/workspace/svg_upload.cljs index 2c292ca7a8..a65b232363 100644 --- a/frontend/src/app/main/data/workspace/svg_upload.cljs +++ b/frontend/src/app/main/data/workspace/svg_upload.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.svg-upload (:require diff --git a/frontend/src/app/main/data/workspace/text/shortcuts.cljs b/frontend/src/app/main/data/workspace/text/shortcuts.cljs index fddfaf357a..aff322c95d 100644 --- a/frontend/src/app/main/data/workspace/text/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/text/shortcuts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.text.shortcuts (:require @@ -11,6 +11,7 @@ [app.common.types.text :as txt] [app.main.data.shortcuts :as ds] [app.main.data.workspace.texts :as dwt] + [app.main.data.workspace.texts-v3 :as dwt-v3] [app.main.data.workspace.undo :as dwu] [app.main.features :as features] [app.main.fonts :as fonts] @@ -170,6 +171,8 @@ :else props)] (when (and shape props) + (when (features/active-feature? @st/state "text-editor-wasm/v1") + (st/emit! (dwt-v3/v3-update-text-editor-styles (:id shape) props))) (st/emit! (dwt/update-attrs (:id shape) props))))) (defn blend-props diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 2a905523b6..1908884677 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.texts (:require @@ -24,12 +24,15 @@ [app.main.data.changes :as dch] [app.main.data.event :as ev] [app.main.data.helpers :as dsh] + [app.main.data.workspace :as-alias dw] [app.main.data.workspace.common :as dwc] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.modifiers :as dwm] + [app.main.data.workspace.pages :as-alias dwpg] [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.shapes :as dwsh] + [app.main.data.workspace.texts-v3 :as dwt-v3] [app.main.data.workspace.transforms :as dwt] [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.wasm-text :as dwwt] @@ -66,15 +69,19 @@ "Marks `ids` pending until the text pipeline marks its own work: `:text-measure` in the DOM renderer, `:text-resize` in wasm. Emits nothing." [ids] - (->> (rx/from ids) - ;; Each id owns its bridge. Starting work for one text must not release - ;; siblings that the renderer has not picked up yet. - (rx/mapcat - (fn [id] - (->> (wrf/pending-signal [id] #{:text-measure :text-resize}) - (rx/ignore) - (wrf/with-pending :text-bridge [id])))) - (rx/ignore))) + (wrf/bridge-pending ids #{:text-measure :text-resize} :text-bridge)) + +(defn- page-finalize? + [event] + (= ::dwpg/finalize-page (ptk/type event))) + +(defn- text-work-stopper + [stream] + (rx/filter + (fn [event] + (or (= ::dw/finalize-workspace (ptk/type event)) + (page-finalize? event))) + stream)) (defn initialize-text-reflow "Tracks the texts the DOM pipeline still has to re-measure, so a reflow wait @@ -83,11 +90,15 @@ (ptk/reify ::initialize-text-reflow ptk/WatchEvent (watch [_ _ stream] - (let [stopper (rx/filter (ptk/type? ::finalize-text-reflow) stream)] + (let [stopper (rx/filter (ptk/type? ::finalize-text-reflow) stream) + page-stopper (rx/filter page-finalize? stream)] (->> stream (rx/filter (ptk/type? :text/reflow)) (rx/map deref) - (rx/mapcat (fn [{:keys [ids]}] (bridge-to-measurement ids))) + (rx/merge-map + (fn [{:keys [ids]}] + (->> (bridge-to-measurement ids) + (rx/take-until page-stopper)))) (rx/take-until stopper)))))) (defn finalize-text-reflow @@ -110,28 +121,51 @@ :else []))) +(defn- await-font-faces + "Waits for missing WASM faces, then resizes the affected texts." + [stream face-keys ids] + (let [resize-stream (->> (rx/from ids) (rx/map dwwt/resize-wasm-text))] + (if (empty? face-keys) + resize-stream + (->> (rx/merge wasm.fonts/font-stored-stream + wasm.fonts/font-storage-failed-stream) + (rx/filter face-keys) + (rx/scan disj face-keys) + (rx/filter empty?) + (rx/take 1) + (rx/take-until (text-work-stopper stream)) + (rx/observe-on :async) + (rx/mapcat (constantly resize-stream)) + (wrf/with-pending :font ids))))) + +(defn- pending-font-faces + [ids] + (let [objects (dsh/lookup-page-objects @st/state)] + (into #{} + (comp + (map #(get objects %)) + (keep :content) + (mapcat wasm.fonts/get-content-fonts) + (map wasm.fonts/make-font-data) + (remove wasm.fonts/font-ready?) + (map wasm.fonts/font-data-key)) + ids))) + (defn- await-font-resize - "Marks `ids` as pending font work and dispatches their wasm resize once wasm - can measure with `font-id`, draining the marks afterwards. The fetch of that - font is started by the wasm shape sync of the content change these shapes - receive, so measuring before it lands would use the fallback font." - [stream font-id ids] + "Waits for missing font faces, then resizes `ids`." + [stream ids] (if (empty? ids) (rx/empty) - (let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)] - (->> wasm.fonts/font-stored-stream - (rx/filter #(= % font-id)) - (rx/take 1) - (rx/take-until stopper) - (rx/observe-on :async) - (rx/mapcat (fn [_] (rx/from (mapv dwwt/resize-wasm-text ids)))) - (wrf/with-pending :font ids))))) + (->> (rx/of ::await-fonts) + (rx/mapcat + (fn [_] + (await-font-faces stream (pending-font-faces ids) ids)))))) (defn- await-html-font "Keeps legacy DOM text pending while its new font is loading. The DOM measurement also awaits this promise, so the font task bridges the state update to the renderer commit without relying on a fixed settle delay." - [font-id font-variant-id ids] + [stream font-id font-variant-id ids] (if (or (nil? font-id) (empty? ids)) (rx/empty) (->> (rx/of ::load-font) @@ -140,7 +174,13 @@ ;; gap before the task is visible to waiters. (rx/mapcat (fn [_] (rx/from (fonts/ensure-loaded! font-id font-variant-id)))) - (rx/ignore) + (rx/take-until (text-work-stopper stream)) + (rx/mapcat (fn [_] + (st/emit! (dwsh/update-shapes + ids + #(dissoc % :position-data) + {:save-undo? false})) + (rx/empty))) (wrf/with-pending :font ids)))) ;; -- Content helpers @@ -525,7 +565,7 @@ [id start end attrs] (ptk/reify ::update-text-range ptk/WatchEvent - (watch [_ state _] + (watch [_ state stream] (let [objects (dsh/lookup-page-objects state) shape (get objects id) @@ -547,7 +587,7 @@ (rx/map dwwt/resize-wasm-text-debounce)) (contains? attrs :font-id) - (await-html-font (:font-id attrs) (:font-variant-id attrs) text-ids) + (await-html-font stream (:font-id attrs) (:font-variant-id attrs) text-ids) :else (rx/empty))))))) @@ -699,13 +739,19 @@ (rx/concat (rx/of (dwsh/update-shapes shape-ids update-shape options)) (when (features/active-feature? state "text-editor-wasm/v1") - (let [styles ((comp update-node-fn migrate-node)) - result (wasm.api/apply-styles-to-selection styles)] + ;; Transform each span so add-fill preserves its existing fills. + (let [result (wasm.api/apply-styles-to-selection + (comp update-node-fn migrate-node) + {:with-fills? true})] (when result (rx/of (v2-update-text-shape-content (:shape-id result) (:content result) - :update-name? true))))))))) + :update-name? true) + ;; Refresh the panel now, not only after a reselect. + (dwt-v3/v3-update-text-editor-styles + (:shape-id result) + {:fills (:fills result)}))))))))) ptk/EffectEvent (effect [_ state _] @@ -798,7 +844,7 @@ (watch [_ state stream] (wrf/start! reflow-task) (if (= (::resize-text-debounce-event state) cur-event) - (let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))] + (let [stopper (->> stream (rx/filter (ptk/type? ::dw/finalize-workspace)))] (rx/concat (rx/merge (->> stream @@ -809,22 +855,23 @@ (rx/take-until stopper)) (rx/of (resize-text id new-width new-height))) (rx/of (fn [state] - (run! wrf/finish! (::resize-text-reflow-tasks state)) + (wrf/finish-tasks! (::resize-text-reflow-tasks state)) (dissoc state ::resize-text-debounce-props ::resize-text-reflow-tasks ::resize-text-debounce-event))))) (rx/empty)))))) -(defn save-font +(defn save-default-font [data] - (ptk/reify ::save-font + (ptk/reify ::save-default-font ptk/UpdateEvent (update [_ state] - (let [multiple? (->> data vals (d/seek #(= % :multiple)))] + (let [multiple? (->> data vals (d/seek #(= % :multiple))) + font (dissoc data :typography-ref-id :typography-ref-file)] (cond-> state (not multiple?) - (assoc-in [:workspace-global :default-font] data)))))) + (update :workspace-global assoc :default-font font)))))) (defn apply-text-modifier [shape text-modifier] @@ -877,7 +924,7 @@ ptk/WatchEvent (watch [_ state stream] (if (= (::update-text-modifier-debounce-event state) cur-event) - (let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))] + (let [stopper (->> stream (rx/filter (ptk/type? ::dw/finalize-workspace)))] (rx/concat (rx/merge (->> stream @@ -925,40 +972,49 @@ ptk/WatchEvent (watch [_ state _] (let [position-data (::update-position-data state)] - (rx/concat - (rx/of (dwsh/update-shapes - (keys position-data) - (fn [shape] - (-> shape - (assoc :position-data (get position-data (:id shape))))) - {:stack-undo? true :reg-objects? false})) - (rx/of (fn [state] - (dissoc state ::update-position-data-debounce ::update-position-data)))))))) + (rx/of (dwsh/update-shapes + (keys position-data) + (fn [shape] + (-> shape + (assoc :position-data (get position-data (:id shape))))) + {:stack-undo? true :reg-objects? false})))))) (defn update-position-data [id position-data] - (let [cur-event (js/Symbol)] + (let [cur-event (js/Symbol) + reflow-task (wrf/task :text-position [id])] (ptk/reify ::update-position-data ptk/UpdateEvent (update [_ state] (let [state (assoc-in state [:workspace-text-modifier id :position-data] position-data)] - (if (nil? (::update-position-data-debounce state)) - (assoc state ::update-position-data-debounce cur-event) - (assoc-in state [::update-position-data id] position-data)))) + (-> state + (update ::update-position-data-reflow-tasks (fnil conj []) reflow-task) + (cond-> (nil? (::update-position-data-debounce state)) + (assoc ::update-position-data-debounce cur-event)) + (cond-> (some? (::update-position-data-debounce state)) + (assoc-in [::update-position-data id] position-data))))) ptk/WatchEvent (watch [_ state stream] + (wrf/start! reflow-task) (if (= (::update-position-data-debounce state) cur-event) - (let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))] - (rx/merge - (->> stream - (rx/filter (ptk/type? ::update-position-data)) - (rx/debounce 50) - (rx/take 1) - (rx/map #(commit-position-data)) - (rx/take-until stopper)) - (rx/of (update-position-data id position-data)))) + (let [stopper (text-work-stopper stream)] + (rx/concat + (rx/merge + (->> stream + (rx/filter (ptk/type? ::update-position-data)) + (rx/debounce 50) + (rx/take 1) + (rx/map #(commit-position-data)) + (rx/take-until stopper)) + (rx/of (update-position-data id position-data))) + (rx/of (fn [state] + (wrf/finish-tasks! (::update-position-data-reflow-tasks state)) + (dissoc state + ::update-position-data-debounce + ::update-position-data + ::update-position-data-reflow-tasks))))) (rx/empty)))))) (defn update-attrs @@ -968,7 +1024,14 @@ (watch [_ state stream] (let [text-editor-instance (:workspace-editor state) objects (dsh/lookup-page-objects state) - text-ids (resolve-text-ids objects id)] + text-ids (resolve-text-ids objects id) + + wasm-editing? + (and (features/active-feature? state "text-editor-wasm/v1") + (= id (wasm.api/text-editor-get-active-shape-id))) + + wasm-editing-selection? + (and wasm-editing? (wasm.api/text-editor-has-selection?))] (if (and (features/active-feature? state "text-editor/v2") (some? text-editor-instance)) (rx/empty) @@ -978,15 +1041,40 @@ (rx/of (update-root-attrs {:id id :attrs attrs})) (rx/empty))) - (let [attrs (select-keys attrs txt/paragraph-attrs)] - (if-not (empty? attrs) - (rx/of (update-paragraph-attrs {:id id :attrs attrs})) - (rx/empty))) + ;; `:line-height` is stored on both the paragraph and its spans, and + ;; the renderer takes the larger of the two. + (let [pattrs (if wasm-editing-selection? + (conj txt/paragraph-attrs :line-height) + txt/paragraph-attrs) + attrs (select-keys attrs pattrs) + result (when (and (seq attrs) wasm-editing?) + (wasm.api/apply-paragraph-attrs-to-selection attrs))] + (cond + (empty? attrs) + (rx/empty) + + (some? result) + (rx/of (v2-update-text-shape-content + (:shape-id result) (:content result) + :update-name? true)) + + :else + (rx/of (update-paragraph-attrs {:id id :attrs attrs})))) (let [attrs (select-keys attrs txt/text-node-attrs)] - (if-not (empty? attrs) - (rx/of (update-text-attrs {:id id :attrs attrs})) - (rx/empty))) + (cond + (or (empty? attrs) wasm-editing-selection?) + (rx/empty) + + ;; Collapsed caret: stash a pending caret style for the next typed + ;; character instead of restyling the whole shape. + wasm-editing? + (do + (wasm.text-editor/merge-pending-caret-styles! id attrs) + (rx/of (dwt-v3/v3-update-text-editor-styles id attrs))) + + :else + (rx/of (update-text-attrs {:id id :attrs attrs})))) (when (and (features/active-feature? state "text-editor/v2") (not (features/active-feature? state "text-editor-wasm/v1"))) @@ -1009,7 +1097,7 @@ (let [auto-ids (into [] (remove #(= :fixed (:grow-type (get objects %)))) text-ids)] (if (contains? attrs :font-id) ;; The geometry depends on the font, so wait until wasm has it. - (await-font-resize stream (:font-id attrs) auto-ids) + (await-font-resize stream auto-ids) ;; No font change: measurable right away. (->> (rx/from auto-ids) (rx/map dwwt/resize-wasm-text))))) @@ -1018,6 +1106,7 @@ ;; but font loading starts before that render commits. (if (contains? attrs :font-id) (await-html-font + stream (:font-id attrs) (:font-variant-id attrs) text-ids) @@ -1208,7 +1297,7 @@ Includes :name when update-name? so we can skip save-undo on the preceding update-shapes for finalize without losing name undo." [it state id {:keys [new-shape? content-has-text? content original-content - update-name? name]}] + update-name? name resize-geom]}] (let [page-id (:current-page-id state) objects (dsh/lookup-page-objects state page-id) shape* (get objects id) @@ -1220,7 +1309,8 @@ (cond-> new-shape? (-> (pcb/set-undo-group id) (pcb/set-stack-undo? true)))) - final-geom (select-keys shape* [:selrect :points :width :height]) + ;; `resize-geom` is the post-resize geometry; `shape*` still holds the pre-resize selrect. + final-geom (or resize-geom (select-keys shape* [:selrect :points :width :height])) geom-keys (if new-shape? [:selrect :points] [:selrect :points :width :height]) old-geom (when (and content-has-text? (not= :fixed (:grow-type shape*))) (or (get-in state [:workspace-text-session-geom id]) @@ -1272,6 +1362,13 @@ ;; modifier machinery, made auto-width typing very laggy. new-size (when (and finalize? (not= :fixed (:grow-type shape))) (dwwt/get-wasm-text-new-size shape content)) + ;; Also compute the resized geometry for the finalize commit; the + ;; async `apply-wasm-modifiers` below never updates this `state`. + resize-modifiers (when (some? new-size) + (dwwt/resize-wasm-text-modifiers shape content)) + resize-geom (when resize-modifiers + (-> (gsh/transform-shape shape (get-in resize-modifiers [id :modifiers])) + (select-keys [:selrect :points :width :height]))) ;; New shapes: single undo on finalize only (no per-keystroke undo) effective-save-undo? (if new-shape? finalize? save-undo?) effective-stack-undo? (and new-shape? finalize?) @@ -1281,7 +1378,16 @@ finalize-save-undo-first? (if (and finalize? (or (not new-shape?) (not content-has-text?))) false - effective-save-undo?)] + effective-save-undo?) + + ;; Whether any content-changing edit happened this editing session. + session-touched? (some? (get-in state [:workspace-text-session-geom id])) + ;; A finalize on an existing shape that wasn't edited must not create any undo entry + ;; (exception being newly created shapes) + finalize-no-op? (and finalize? + (not new-shape?) + content-has-text? + (not session-touched?))] (rx/concat (rx/of @@ -1311,12 +1417,10 @@ :stack-undo? effective-stack-undo? :undo-group (when new-shape? id)}) - ;; `new-size` is only computed on finalize (see above), so this commits - ;; the final auto-width/auto-height geometry via `apply-wasm-modifiers` - ;; like other transform flows (flex parents, sidebar width, etc.). - (when (some? new-size) - (when-let [modifiers (dwwt/resize-wasm-text-modifiers shape content)] - (dwm/apply-wasm-modifiers modifiers {:undo-group (when new-shape? id)})))) + ;; Push the auto-grow geometry to WASM/app state; the commit persists it via `resize-geom`. + ;; Skipped for a no-op finalize: applying it would record an undo transaction. + (when (and (some? resize-modifiers) (not finalize-no-op?)) + (dwm/apply-wasm-modifiers resize-modifiers {:undo-group (when new-shape? id)}))) (when finalize? (rx/concat @@ -1333,7 +1437,7 @@ (dwsh/delete-shapes #{id}))) (rx/empty)) (rx/concat - (if content-has-text? + (if (and content-has-text? (not finalize-no-op?)) (rx/of (dch/commit-changes (build-finalize-commit-changes it state id @@ -1349,7 +1453,8 @@ ;; behavior (their create is bundled in the undo group). :original-content (if new-shape? original-content prev-content) :update-name? update-name? - :name name}))) + :name name + :resize-geom resize-geom}))) (rx/empty)) (rx/of (dwt/finish-transform) (fn [state] diff --git a/frontend/src/app/main/data/workspace/texts_v3.cljs b/frontend/src/app/main/data/workspace/texts_v3.cljs index c0b27abe95..d9c288eac3 100644 --- a/frontend/src/app/main/data/workspace/texts_v3.cljs +++ b/frontend/src/app/main/data/workspace/texts_v3.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.texts-v3 (:require diff --git a/frontend/src/app/main/data/workspace/thumbnails.cljs b/frontend/src/app/main/data/workspace/thumbnails.cljs index 3526c1ed99..ebc6e79d9e 100644 --- a/frontend/src/app/main/data/workspace/thumbnails.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.thumbnails (:require diff --git a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs index edef890e1e..4bcfe857f6 100644 --- a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.thumbnails-wasm "WASM-based component thumbnail rendering. diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 15592b3b13..dd96ebf89c 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.application (:require diff --git a/frontend/src/app/main/data/workspace/tokens/color.cljs b/frontend/src/app/main/data/workspace/tokens/color.cljs index 4c6035bee5..a713e76d0f 100644 --- a/frontend/src/app/main/data/workspace/tokens/color.cljs +++ b/frontend/src/app/main/data/workspace/tokens/color.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.color (:require diff --git a/frontend/src/app/main/data/workspace/tokens/errors.cljs b/frontend/src/app/main/data/workspace/tokens/errors.cljs index e5716f07be..bb4d06ded7 100644 --- a/frontend/src/app/main/data/workspace/tokens/errors.cljs +++ b/frontend/src/app/main/data/workspace/tokens/errors.cljs @@ -2,10 +2,11 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.errors (:require + [app.util.dom :as dom] [app.util.i18n :refer [tr]] [cuerdas.core :as str])) @@ -25,12 +26,12 @@ :error.import/invalid-token-name {:error/code :error.import/invalid-token-name :error/fn #(tr "errors.tokens.invalid-json-token-name") - :error/detail #(tr "errors.tokens.invalid-json-token-name-detail" %)} + :error/detail #(tr "errors.tokens.invalid-json-token-name-detail" (dom/escape-html %))} :error.import/style-dictionary-reference-errors {:error/code :error.import/style-dictionary-reference-errors :error/fn #(str (tr "errors.tokens.import-error") "\n\n" (first %)) - :error/detail #(str/join "\n\n" (rest %))} + :error/detail #(str/join "\n\n" (map dom/escape-html (rest %)))} :error.import/style-dictionary-unknown-error {:error/code :error.import/style-dictionary-reference-errors diff --git a/frontend/src/app/main/data/workspace/tokens/import_export.cljs b/frontend/src/app/main/data/workspace/tokens/import_export.cljs index 5dded5bbb3..38abfe710b 100644 --- a/frontend/src/app/main/data/workspace/tokens/import_export.cljs +++ b/frontend/src/app/main/data/workspace/tokens/import_export.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.import-export (:require @@ -16,6 +16,7 @@ [app.main.data.tokenscript :as ts] [app.main.data.workspace.tokens.errors :as wte] [app.main.store :as st] + [app.util.dom :as dom] [app.util.i18n :as i18n] [beicon.v2.core :as rx] [cuerdas.core :as str])) @@ -54,14 +55,15 @@ (l/wrn :hint "unsupported token types found during import" :tokens (str/join ", " (map (fn [[path type]] (str path " (" type ")")) unknown-tokens))) (ntf/show {:content (i18n/tr "workspace.tokens.unknown-token-type-message") + :is-html true :detail (->> (for [[token-type token-paths] type->tokens] (str (i18n/tr "workspace.tokens.unknown-token-type-section" - token-type + (dom/escape-html token-type) (i18n/tr "labels.warning-count" (i18n/c (count token-paths)))) "<ul>" (->> token-paths (sort) - (map #(str "<li>" % "</li>")) + (map #(str "<li>" (dom/escape-html %) "</li>")) (str/join "")) "</ul>")) (str/join "")) diff --git a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs index 5ccf4c2a31..b5b39a467e 100644 --- a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs +++ b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.library-edit (:require diff --git a/frontend/src/app/main/data/workspace/tokens/propagation.cljs b/frontend/src/app/main/data/workspace/tokens/propagation.cljs index 3685e77970..9440b32c81 100644 --- a/frontend/src/app/main/data/workspace/tokens/propagation.cljs +++ b/frontend/src/app/main/data/workspace/tokens/propagation.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.propagation (:require diff --git a/frontend/src/app/main/data/workspace/tokens/remapping.cljs b/frontend/src/app/main/data/workspace/tokens/remapping.cljs index c9c15224eb..e36b9d1b1d 100644 --- a/frontend/src/app/main/data/workspace/tokens/remapping.cljs +++ b/frontend/src/app/main/data/workspace/tokens/remapping.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.remapping "Core logic for token remapping functionality" diff --git a/frontend/src/app/main/data/workspace/tokens/selected_set.cljs b/frontend/src/app/main/data/workspace/tokens/selected_set.cljs index 5189f03cab..29036cd8cb 100644 --- a/frontend/src/app/main/data/workspace/tokens/selected_set.cljs +++ b/frontend/src/app/main/data/workspace/tokens/selected_set.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.selected-set "The user selected token set in the ui, stored by the `:name` of the set. diff --git a/frontend/src/app/main/data/workspace/tokens/typography.cljs b/frontend/src/app/main/data/workspace/tokens/typography.cljs index 1172ad60c5..a33249835f 100644 --- a/frontend/src/app/main/data/workspace/tokens/typography.cljs +++ b/frontend/src/app/main/data/workspace/tokens/typography.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.typography (:require diff --git a/frontend/src/app/main/data/workspace/tokens/warnings.cljs b/frontend/src/app/main/data/workspace/tokens/warnings.cljs index 594d20380d..9782d99320 100644 --- a/frontend/src/app/main/data/workspace/tokens/warnings.cljs +++ b/frontend/src/app/main/data/workspace/tokens/warnings.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.warnings (:require diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index d0e612b493..aa43f69c1d 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.transforms "Events related with shapes transformations" @@ -23,6 +23,8 @@ [app.common.types.component :as ctk] [app.common.types.container :as ctn] [app.common.types.modifiers :as ctm] + [app.common.types.path :as path] + [app.common.types.path.helpers :as path.helpers] [app.common.types.shape-tree :as ctst] [app.common.types.shape.attrs :refer [editable-attrs]] [app.common.types.shape.layout :as ctl] @@ -363,6 +365,71 @@ (dwm/apply-modifiers) (finish-transform)))))))))))) +(defn start-move-line-point + "Drags one endpoint of a straight path while keeping the other fixed." + [shape index] + (ptk/reify ::start-move-line-point + ptk/WatchEvent + (watch [_ state stream] + (let [id (dm/get-prop shape :id) + page-id (:current-page-id state) + objects (dsh/lookup-page-objects state page-id) + zoom (dm/get-in state [:workspace-local :zoom] 1) + layout (:workspace-layout state) + focus (:workspace-focus-selected state) + + content (dm/get-prop shape :content) + start-point (path.helpers/segment->point (nth content index)) + other-point (path.helpers/segment->point (nth content (if (zero? index) 1 0))) + + stopper (mse/drag-stopper stream) + + ;; Shift constrains the endpoint around the fixed point. + position-stream + (->> ms/mouse-position + (rx/filter some?) + (rx/with-latest-from ms/mouse-position-shift) + (rx/switch-map + (fn [[pos shift?]] + (if ^boolean shift? + (rx/of (path.helpers/position-fixed-angle pos other-point)) + (snap/closest-snap-point page-id [shape] objects layout zoom focus pos)))) + (rx/share)) + + move-endpoint + (fn [pos save-undo?] + (let [delta (gpt/to-vec start-point pos)] + (dwsh/update-shapes + [id] + (fn [_] + (-> shape + (assoc :content (path/apply-content-modifiers + content + {index {:x (dm/get-prop delta :x) + :y (dm/get-prop delta :y)}})) + (path/update-geometry))) + {:reg-objects? true :save-undo? save-undo?})))] + + ;; Hide selection controls during the drag. + (rx/concat + (rx/of #(assoc-in % [:workspace-local :transform] :move)) + ;; Subscribe the preview and commit branches together. + (rx/merge + ;; Preview without creating undo entries. + (->> position-stream + (rx/sample mconst/move-sample-time) + (rx/map #(move-endpoint % false)) + (rx/take-until stopper)) + ;; Commit the final position as one undo step. + (->> position-stream + (rx/take-until stopper) + (rx/last) + (rx/mapcat + (fn [pos] + (rx/of (move-endpoint start-point false) + (move-endpoint pos true)))))) + (rx/of #(assoc-in % [:workspace-local :transform] nil))))))) + (defn trigger-bounding-box-cloaking "Trigger the bounding box cloaking (with default timer of 1sec) @@ -1125,19 +1192,169 @@ :ignore-touched (:ignore-touched options) :ignore-snap-pixel true})))))))) +;; -- Sidebar measures transform coalescing ---------------------------- + +;; The sidebar measures panel numeric inputs emit one event per DOM +;; gesture tick (held arrow keys, mouse wheel, scrub drags). Committing +;; each tick would run a full `apply-modifiers` per DOM event and starve +;; the renderer (React error #185). The events in this section coalesce +;; those bursts at the data layer: the first event of a burst commits +;; immediately (leading edge, so single edits stay synchronous), further +;; ticks commit at most once per `mconst/sidebar-transform-sample-time` +;; (throttle), and a trailing debounced flush guarantees the exact final +;; value lands. All payloads are absolute values, so keeping only the +;; latest queued value per shape/attribute is lossless. + +(defn- sidebar-commit-events + "Build the real commit events for a drained pending entry of `kind`, + skipping shapes that no longer exist on the queued page." + [state kind entry] + (let [options (:options entry) + page-id (or (:page-id options) (:current-page-id state)) + objects (dsh/lookup-page-objects state page-id) + options (assoc options :page-id page-id) + live-ids (fn [ids] (into [] (filter #(contains? objects %)) ids))] + (case kind + ::positions + (keep (fn [[id position]] + (when (contains? objects id) + (update-position id position options))) + (:positions entry)) + + ::dimensions + (let [ids (live-ids (:ids entry))] + (when (seq ids) + (map (fn [[attr value]] + (update-dimensions ids attr value options)) + (:values entry)))) + + ::rotation + (let [ids (live-ids (:ids entry))] + (when (seq ids) + [(increase-rotation ids (:value entry) nil :page-id page-id)]))))) + +(defn- flush-sidebar-transforms + "Internal: atomically drain the pending sidebar transform payloads and + emit their commit events. No-op when nothing is pending." + [] + (ptk/reify ::flush-sidebar-transforms + ptk/UpdateEvent + (update [_ state] + (let [pending (::pending-sidebar-transforms state)] + (-> state + (dissoc ::pending-sidebar-transforms) + (assoc ::flushing-sidebar-transforms pending)))) + + ptk/WatchEvent + (watch [_ state _] + (let [pending (::flushing-sidebar-transforms state)] + (rx/concat + (if (empty? pending) + (rx/empty) + (->> pending + (mapcat (fn [[kind entry]] (sidebar-commit-events state kind entry))) + (rx/from))) + (rx/of (fn [state] (dissoc state ::flushing-sidebar-transforms)))))))) + +(defn- queue-sidebar-transform + "Internal: accumulate the latest payload of `kind` with `update-entry` + (a fn from the previous pending entry to the new one). + + The very first queued event of the workspace session also installs the + drain stream that commits pending payloads: a leading flush for the + first event, at most one flush per + `mconst/sidebar-transform-sample-time` while a burst is ongoing + (throttle), and a trailing flush (debounce) that guarantees the exact + final value lands. The drain stream lives until the workspace is + finalized, so subsequent bursts reuse it." + [kind update-entry] + (let [cur-event (js/Symbol)] + (ptk/reify ::queue-sidebar-transform + ptk/UpdateEvent + (update [_ state] + (let [state (update-in state [::pending-sidebar-transforms kind] + (fn [entry] (update-entry (or entry {}))))] + (if (nil? (::sidebar-transform-drain state)) + (assoc state ::sidebar-transform-drain cur-event) + state))) + + ptk/WatchEvent + (watch [_ state stream] + (if (= cur-event (::sidebar-transform-drain state)) + (let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))] + (rx/merge + ;; Leading edge: commit the payload this first event queued. + (rx/of (flush-sidebar-transforms)) + ;; At most one commit per window while a burst is ongoing. + (->> stream + (rx/filter (ptk/type? ::queue-sidebar-transform)) + (rx/throttle mconst/sidebar-transform-sample-time) + (rx/map (fn [_] (flush-sidebar-transforms))) + (rx/take-until stopper)) + ;; Trailing edge: guarantee the exact final value lands. + (->> stream + (rx/filter (ptk/type? ::queue-sidebar-transform)) + (rx/debounce mconst/sidebar-transform-sample-time) + (rx/map (fn [_] (flush-sidebar-transforms))) + (rx/take-until stopper)))) + (rx/empty)))))) + (defn update-positions - "Move multiple shapes to a new position." + "Move multiple shapes to a new position, from the sidebar options form. + + Burst-coalesced (see `queue-sidebar-transform`): rapid successive calls + from the sidebar numeric inputs commit at most once per + `mconst/sidebar-transform-sample-time`, and the trailing flush commits + the exact final position. A single call still commits synchronously." ([ids position] (update-positions ids position nil)) ([ids position options] (assert (every? uuid? ids) "expected valid coll of uuids") (assert (map? position) "expected a valid map for `position`") - (ptk/reify ::update-positions - ptk/WatchEvent - (watch [_ _ _] - (->> ids - (map (fn [id] (update-position id position options))) - (rx/from)))))) + (queue-sidebar-transform + ::positions + (fn [entry] + (-> entry + (update :positions + (fn [positions] + (reduce (fn [positions id] + (update positions id merge position)) + (or positions {}) + ids))) + (assoc :options options)))))) + +(defn update-dimensions-coalesced + "Like `update-dimensions`, but burst-coalesced (see + `queue-sidebar-transform`); used by the sidebar measures panel numeric + inputs. The latest queued value per attribute wins." + ([ids attr value] (update-dimensions-coalesced ids attr value nil)) + ([ids attr value options] + (assert (number? value)) + (assert (every? uuid? ids) + "expected valid coll of uuids") + (assert (contains? #{:width :height} attr) + "expected valid attr") + (queue-sidebar-transform + ::dimensions + (fn [entry] + (-> entry + (assoc-in [:values attr] value) + (assoc :ids ids :options options)))))) + +(defn increase-rotation-coalesced + "Like `increase-rotation` with an absolute rotation value, but + burst-coalesced (see `queue-sidebar-transform`); used by the sidebar + measures panel rotation input. The latest queued absolute value wins; + the delta is recomputed from the current rotation when the burst + commits." + [ids rotation] + (assert (every? uuid? ids) + "expected valid coll of uuids") + (assert (number? rotation)) + (queue-sidebar-transform + ::rotation + (fn [entry] + (assoc entry :value rotation :ids ids :options nil)))) (defn position-shapes [shapes] diff --git a/frontend/src/app/main/data/workspace/undo.cljs b/frontend/src/app/main/data/workspace/undo.cljs index 640df965b4..156bc5b7cb 100644 --- a/frontend/src/app/main/data/workspace/undo.cljs +++ b/frontend/src/app/main/data/workspace/undo.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.undo "Undo management for the workspace. diff --git a/frontend/src/app/main/data/workspace/variants.cljs b/frontend/src/app/main/data/workspace/variants.cljs index 2d224facc2..9a3ee11095 100644 --- a/frontend/src/app/main/data/workspace/variants.cljs +++ b/frontend/src/app/main/data/workspace/variants.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.variants (:require @@ -335,14 +335,15 @@ (let [page-id (:current-page-id state) objects (dsh/lookup-page-objects state page-id) shape (get objects shape-id) - container (get objects (:parent-id shape)) - width (+ (:width container) (:width shape) 20) ;; 20 is the default gap for variants - x (- width (+ (:width shape) 30))] ;; 30 is the default margin for variants - (rx/of - (dwt/update-dimensions [(:parent-id shape)] :width width) - (dwt/update-position shape-id - {:x x} - {:absolute? false})))))) + container (get objects (:parent-id shape))] + (when (and (some? shape) (some? container)) + (let [width (+ (:width container) (:width shape) 20) ;; 20 is the default gap for variants + x (- width (+ (:width shape) 30))] ;; 30 is the default margin for variants + (rx/of + (dwt/update-dimensions [(:parent-id shape)] :width width) + (dwt/update-position shape-id + {:x x} + {:absolute? false})))))))) (defn add-new-variant "Create a new variant and add it to the variant-container" @@ -359,39 +360,40 @@ shape (get objects shape-id) shape (if (ctc/is-variant-container? shape) (get objects (last (:shapes shape))) - shape) - component-id (:component-id shape) - component (ctkl/get-component data component-id) + shape)] + (when (some? shape) + (let [component-id (:component-id shape) + component (ctkl/get-component data component-id) - container-id (:parent-id shape) - variant-container (get objects container-id) - has-layout? (ctsl/any-layout? variant-container) + container-id (:parent-id shape) + variant-container (get objects container-id) + has-layout? (ctsl/any-layout? variant-container) - new-component-id (uuid/next) - new-shape-id (uuid/next) + new-component-id (uuid/next) + new-shape-id (uuid/next) - prop-num (dec (count (:variant-properties component))) + prop-num (dec (count (:variant-properties component))) - changes (-> (pcb/empty-changes it page-id) - (pcb/with-library-data data) - (pcb/with-objects objects) - (pcb/with-page-id page-id) - (clv/generate-add-new-variant shape (:variant-id component) new-component-id new-shape-id prop-num)) + changes (-> (pcb/empty-changes it page-id) + (pcb/with-library-data data) + (pcb/with-objects objects) + (pcb/with-page-id page-id) + (clv/generate-add-new-variant shape (:variant-id component) new-component-id new-shape-id prop-num)) - undo-id (js/Symbol)] - (rx/concat - (rx/of - (dwu/start-undo-transaction undo-id) - (dch/commit-changes changes) - (when-not has-layout? - (resposition-and-resize-variant new-shape-id)) - (dwu/commit-undo-transaction undo-id) - (ptk/data-event :layout/update {:ids [(:parent-id shape)]}) - (if multiselect? - (dws/shift-select-shapes new-shape-id) - (dws/select-shape new-shape-id))) - (->> (rx/of (focus-property (:id variant-container))) - (rx/delay 250)))))))) + undo-id (js/Symbol)] + (rx/concat + (rx/of + (dwu/start-undo-transaction undo-id) + (dch/commit-changes changes) + (when-not has-layout? + (resposition-and-resize-variant new-shape-id)) + (dwu/commit-undo-transaction undo-id) + (ptk/data-event :layout/update {:ids [(:parent-id shape)]}) + (if multiselect? + (dws/shift-select-shapes new-shape-id) + (dws/select-shape new-shape-id))) + (->> (rx/of (focus-property (:id variant-container))) + (rx/delay 250)))))))))) (defn transform-in-variant "Given the id of a main shape of a component, creates a variant structure for @@ -539,9 +541,10 @@ (let [objects (dsh/lookup-page-objects state) selected-ids (dsh/lookup-selected state) selected-shapes (map (d/getf objects) selected-ids) - add-new-variant? (every? ctc/is-variant? selected-shapes) + add-new-variant? (and (seq selected-shapes) (every? ctc/is-variant? selected-shapes)) undo-id (js/Symbol)] - (if add-new-variant? + (cond + add-new-variant? (rx/concat (rx/of (ev/event {::ev/name "add-new-variant" ::ev/origin "workspace:shortcut-duplicate"}) @@ -549,7 +552,12 @@ (add-new-variant (first selected-ids) false)) (rx/from (map #(add-new-variant % true) (rest selected-ids))) (rx/of (dwu/commit-undo-transaction undo-id))) - (rx/of (dws/duplicate-selected true))))))) + + (seq selected-ids) + (rx/of (dws/duplicate-selected true)) + + :else + (rx/empty)))))) (defn rename-variant "Rename the variant container and all components belonging to this variant" diff --git a/frontend/src/app/main/data/workspace/versions.cljs b/frontend/src/app/main/data/workspace/versions.cljs index 7dc4bfbb1f..ae08f31808 100644 --- a/frontend/src/app/main/data/workspace/versions.cljs +++ b/frontend/src/app/main/data/workspace/versions.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.versions (:require diff --git a/frontend/src/app/main/data/workspace/viewport.cljs b/frontend/src/app/main/data/workspace/viewport.cljs index 948ce72d4a..f7570534e3 100644 --- a/frontend/src/app/main/data/workspace/viewport.cljs +++ b/frontend/src/app/main/data/workspace/viewport.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.viewport (:require diff --git a/frontend/src/app/main/data/workspace/viewport_wasm.cljs b/frontend/src/app/main/data/workspace/viewport_wasm.cljs index 1c1caa801b..e8bf9b6885 100644 --- a/frontend/src/app/main/data/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/data/workspace/viewport_wasm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.viewport-wasm (:require diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index eba1fdb8f6..04495ad0f2 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.wasm-text "Helpers/events to resize wasm text shapes without depending on workspace.texts. @@ -16,6 +16,7 @@ [app.common.geom.point :as gpt] [app.common.types.modifiers :as ctm] [app.main.data.helpers :as dsh] + [app.main.data.workspace :as-alias dw] [app.main.data.workspace.modifiers :as dwm] [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.shapes :as dwsh] @@ -93,48 +94,55 @@ (rx/empty))] (wrf/with-pending :text-resize [id] resize-stream))))) +(defn- merge-resize-debounce-opts + [prev {:keys [undo-group undo-id skip-component-sync?]}] + (cond-> (or prev {}) + (some? undo-group) (assoc :undo-group undo-group) + (some? undo-id) (assoc :undo-id undo-id) + skip-component-sync? (assoc :skip-component-sync? true))) + (defn resize-wasm-text-debounce-commit - ([] - (resize-wasm-text-debounce-commit nil nil)) - ([undo-group undo-id] - (ptk/reify ::resize-wasm-text-debounce-commit - ptk/WatchEvent - (watch [_ state _] - (let [ids (get state ::resize-wasm-text-debounce-ids) - objects (dsh/lookup-page-objects state) + [] + (ptk/reify ::resize-wasm-text-debounce-commit + ptk/WatchEvent + (watch [_ state _] + (let [ids (get state ::resize-wasm-text-debounce-ids) + {:keys [undo-group undo-id skip-component-sync?]} (get state ::resize-wasm-text-debounce-opts) + objects (dsh/lookup-page-objects state) - modifiers - (reduce - (fn [modifiers id] - (let [shape (get objects id)] - (cond-> modifiers - (and (some? shape) - (cfh/text-shape? shape) - (not= :fixed (:grow-type shape))) - (merge (resize-wasm-text-modifiers shape))))) - {} - ids) + modifiers + (reduce + (fn [modifiers id] + (let [shape (get objects id)] + (cond-> modifiers + (and (some? shape) + (cfh/text-shape? shape) + (not= :fixed (:grow-type shape))) + (merge (resize-wasm-text-modifiers shape))))) + {} + ids) - ;; When undo-id is present, extend the current undo transaction instead of - ;; creating a new one, and commit it after the resize (single undo action). - extend-tx? (some? undo-id) - apply-opts (cond-> {} - (some? undo-group) (assoc :undo-group undo-group) - extend-tx? (assoc :undo-transation? false))] - (cond - (not (empty? modifiers)) - (if extend-tx? - (rx/concat - (rx/of (dwm/apply-wasm-modifiers modifiers apply-opts)) - (rx/of (dwu/commit-undo-transaction undo-id))) - (rx/of (dwm/apply-wasm-modifiers modifiers apply-opts))) + ;; When undo-id is present, extend the current undo transaction instead of + ;; creating a new one, and commit it after the resize (single undo action). + extend-tx? (some? undo-id) + apply-opts (cond-> {} + (some? undo-group) (assoc :undo-group undo-group) + extend-tx? (assoc :undo-transation? false) + skip-component-sync? (assoc :skip-component-sync? true))] + (cond + (not (empty? modifiers)) + (if extend-tx? + (rx/concat + (rx/of (dwm/apply-wasm-modifiers modifiers apply-opts)) + (rx/of (dwu/commit-undo-transaction undo-id))) + (rx/of (dwm/apply-wasm-modifiers modifiers apply-opts))) - extend-tx? - ;; No resize needed (e.g. :fixed grow-type) but we must commit the add - (rx/of (dwu/commit-undo-transaction undo-id)) + extend-tx? + ;; No resize needed (e.g. :fixed grow-type) but we must commit the add + (rx/of (dwu/commit-undo-transaction undo-id)) - :else - (rx/empty))))))) + :else + (rx/empty)))))) ;; This event will debounce the resize events so, if there are many, they ;; are processed at the same time and not one-by-one. This will improve @@ -143,7 +151,7 @@ (defn resize-wasm-text-debounce-inner ([id] (resize-wasm-text-debounce-inner id nil)) - ([id {:keys [undo-group undo-id]}] + ([id opts] (let [cur-event (js/Symbol) reflow-task (wrf/task :text-resize [id])] (ptk/reify ::resize-wasm-text-debounce-inner @@ -152,6 +160,8 @@ (-> state (update ::resize-wasm-text-debounce-ids (fnil conj []) id) (update ::resize-wasm-text-reflow-tasks (fnil conj []) reflow-task) + (cond-> (seq opts) + (update ::resize-wasm-text-debounce-opts merge-resize-debounce-opts opts)) (cond-> (nil? (::resize-wasm-text-debounce-event state)) (assoc ::resize-wasm-text-debounce-event cur-event)))) @@ -159,37 +169,33 @@ (watch [_ state stream] (wrf/start! reflow-task) (if (= (::resize-wasm-text-debounce-event state) cur-event) - (let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))] + (let [stopper (->> stream (rx/filter (ptk/type? ::dw/finalize-workspace)))] (rx/concat (rx/merge (->> stream (rx/filter (ptk/type? ::resize-wasm-text-debounce-inner)) (rx/debounce debounce-resize-text-time) (rx/take 1) - (rx/map (fn [evt] - (resize-wasm-text-debounce-commit - (some-> evt meta :undo-group) - (some-> evt meta :undo-id)))) + (rx/map (fn [_] (resize-wasm-text-debounce-commit))) (rx/take-until stopper)) - (rx/of (with-meta - (resize-wasm-text-debounce-inner id) - {:undo-group undo-group :undo-id undo-id}))) + (rx/of (resize-wasm-text-debounce-inner id opts))) ;; Cleanup, reached both after the commit and when the stopper ;; cancels the debounce, so the batch always drains and stays ;; pending until the resize is applied. All exact tasks in the ;; batch are retained in state and finished by the cleanup. (rx/of (fn [state] - (run! wrf/finish! (::resize-wasm-text-reflow-tasks state)) + (wrf/finish-tasks! (::resize-wasm-text-reflow-tasks state)) (dissoc state ::resize-wasm-text-debounce-ids ::resize-wasm-text-reflow-tasks + ::resize-wasm-text-debounce-opts ::resize-wasm-text-debounce-event))))) (rx/empty))))))) (defn resize-wasm-text-debounce ([id] (resize-wasm-text-debounce id nil)) - ([id {:keys [undo-group undo-id] :as opts}] + ([id {:keys [undo-group undo-id skip-component-sync?] :as opts}] (ptk/reify ::resize-wasm-text-debounce ptk/WatchEvent (watch [_ state _] @@ -198,19 +204,20 @@ content (dm/get-in objects [id :content]) fonts (wasm.fonts/get-content-fonts content) - fonts-loaded? + fonts-ready? (->> fonts (every? (fn [font] (let [font-data (wasm.fonts/make-font-data font)] - (wasm.fonts/font-stored? font-data (:emoji? font-data)))))) + (wasm.fonts/font-ready? font-data))))) resize-wasm-stream - (if fonts-loaded? - (let [pass-opts (when (or (some? undo-group) (some? undo-id)) + (if fonts-ready? + (let [pass-opts (when (or (some? undo-group) (some? undo-id) skip-component-sync?) (cond-> {} (some? undo-group) (assoc :undo-group undo-group) - (some? undo-id) (assoc :undo-id undo-id)))] + (some? undo-id) (assoc :undo-id undo-id) + skip-component-sync? (assoc :skip-component-sync? true)))] (rx/of (resize-wasm-text-debounce-inner id pass-opts))) ;; Fonts not loaded; retry after 20 msecs @@ -232,15 +239,32 @@ (watch [_ state stream] (let [resize-stream (->> (rx/from ids) - (rx/map #(resize-wasm-text-debounce % opts)))] + (rx/map #(resize-wasm-text-debounce % opts))) + + buffer-finished-stream + (->> (rx/merge + (->> stream + (rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit)) + (rx/map (constantly :commit))) + ;; Let a buffered commit beat the stop signal. + (->> stream + (rx/filter (ptk/type? ::dwsh/update-shapes-buffer-stop)) + (rx/observe-on :async) + (rx/map (constantly :stop))) + (->> stream + (rx/filter (ptk/type? ::dw/finalize-workspace)) + (rx/map (constantly :finalize)))) + (rx/take 1))] (if (::dwsh/update-shapes-buffer state) ;; If we're in the middle of a token propagation we wait until is finished to ;; recalculate the text sizes. The shapes stay pending for that whole wait, ;; since the per-shape debounce only marks them once dispatched. (wrf/with-pending :text-resize ids - (->> stream - (rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit)) - (rx/take 1) - (rx/mapcat (constantly resize-stream)))) + (->> buffer-finished-stream + (rx/mapcat + (fn [reason] + (if (= reason :finalize) + (rx/empty) + resize-stream))))) resize-stream)))))) diff --git a/frontend/src/app/main/data/workspace/zoom.cljs b/frontend/src/app/main/data/workspace/zoom.cljs index 1a5afc7d0b..bf53b9462f 100644 --- a/frontend/src/app/main/data/workspace/zoom.cljs +++ b/frontend/src/app/main/data/workspace/zoom.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.zoom (:require @@ -135,6 +135,32 @@ (effect [_ state _] (dwvw/maybe-sync-workspace-local-viewport! state)))) +(defn center-on-shape + "Pan the viewport to center on the shape with the given id without changing zoom." + [id] + (ptk/reify ::center-on-shape + ptk/UpdateEvent + (update [_ state] + (if (dwvw/render-context-lost? state) + state + (let [page-id (:current-page-id state) + objects (dsh/lookup-page-objects state page-id) + shape (get objects id) + srect (:selrect shape)] + (if (nil? srect) + state + (update state :workspace-local + (fn [{:keys [vbox] :as local}] + (let [cx (+ (:x srect) (/ (:width srect) 2)) + cy (+ (:y srect) (/ (:height srect) 2)) + new-x (- cx (/ (:width vbox) 2)) + new-y (- cy (/ (:height vbox) 2))] + (update local :vbox assoc :x new-x :y new-y)))))))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)))) + (def zoom-to-selected-shape (ptk/reify ::zoom-to-selected-shape ptk/UpdateEvent diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index de4ca4f5bd..85c0e9be3e 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.errors "Generic error handling" @@ -13,6 +13,7 @@ [app.main.data.auth :as da] [app.main.data.event :as ev] [app.main.data.modal :as modal] + [app.main.data.nitrate :as dnt] [app.main.data.notifications :as ntf] [app.main.data.workspace :as-alias dw] [app.main.router :as rt] @@ -21,6 +22,7 @@ [app.util.globals :as g] [app.util.i18n :refer [tr]] [app.util.timers :as ts] + [beicon.v2.core :as rx] [cuerdas.core :as str] [potok.v2.core :as ptk])) @@ -234,9 +236,8 @@ ;; We receive a explicit authentication error; If the uri is for ;; workspace, dashboard, viewer or settings, then assign the exception ;; for show the error page. Otherwise this explicitly clears all -;; profile data and redirect the user to the login page. This is here -;; and not in app.main.errors because of circular dependency. -(defmethod ptk/handle-error :authentication +;; profile data and redirect the user to the login page. +(defn- show-authentication-error [error] (let [message (tr "errors.auth.unable-to-login") uri (rt/get-current-href) @@ -253,6 +254,85 @@ (st/emit! (da/logout)) (ts/schedule 500 #(st/emit! (ntf/warn message))))))) +;; The user does belong to an organization with SSO active, but there is +;; no provider to send them to (unusable or incomplete SSO config). Show +;; the SSO error dialog, which offers an explicit retry, rather than +;; claiming they have no access. +(defn- show-sso-error + [{:keys [organization-id team-id]}] + (let [uri (rt/get-current-href)] + (st/async-emit! + (rt/assign-exception {:type :sso-error + :organization-id organization-id + :team-id team-id + :is-workspace (str/includes? uri "workspace") + :is-dashboard (str/includes? uri "dashboard")})))) + +;; A page issues many SSO-guarded requests at once, and all of them fail +;; together the moment the organization SSO session lapses; without this +;; only-one-in-flight guard each of them would start its own identity +;; provider round-trip. +(def ^:private sso-renewal-pending? (volatile! false)) + +(defn- renew-organization-sso + "Recover from a request rejected by the organization SSO gate. + + Asks the backend what can be done for the current location and acts on + the answer: go through the identity provider when there is one (it + re-authenticates transparently while the user still has a live session + with it), retry the location when the gate turns out to be satisfied + already (another tab renewed the session, or SSO was turned off), show + the SSO error dialog when SSO is required but unusable, and report a + permission failure only when the user really has no access to the team. + A failing check is left to the generic error handling, so a network + blip is not turned into a permission error." + [{:keys [organization-id team-id] :as error}] + (when-not @sso-renewal-pending? + (vreset! sso-renewal-pending? true) + (let [dest-url (rt/get-current-href)] + (->> (dnt/check-organization-sso + {:organization-id organization-id + :team-id team-id + :dest-url dest-url}) + ;; Release the guard however the check ends, including an + ;; unsubscription or a completion without a result: a stuck guard + ;; would silently drop every later rejection. + (rx/finalize (fn [] (vreset! sso-renewal-pending? false))) + (rx/subs! (fn [{:keys [authorized reason redirect-uri]}] + (cond + ;; SSO must be renewed and we know where to send them + (some? redirect-uri) + (st/emit! (rt/nav-raw :uri (str redirect-uri))) + + ;; The gate is satisfied after all, so the request + ;; that failed can be retried. Only an affirmative + ;; reason is accepted here: reloading on any + ;; unrecognized "authorized" answer would spin + ;; whenever the reload hits the same rejection. + (= :sso-satisfied reason) + (st/emit! (rt/reload false)) + + ;; SSO is required but the provider is unusable + (not authorized) + (show-sso-error error) + + ;; No access to the team, so the gate was never + ;; evaluated: this really is a permission failure + :else + (show-authentication-error error))) + on-error))))) + +(defmethod ptk/handle-error :authentication + [error] + ;; Without an organization or a team there is nothing to check, and asking + ;; anyway would fail schema validation and report that instead of the + ;; authentication problem the user actually hit. + (if (and (= :nitrate-sso-required (get error :code)) + (or (some? (get error :organization-id)) + (some? (get error :team-id)))) + (renew-organization-sso error) + (show-authentication-error error))) + ;; Error that happens on an active business model validation does not ;; passes an validation (example: profile can't leave a team). From ;; the user perspective a error flash message should be visualized but @@ -309,6 +389,12 @@ :level :error :timeout 3000}))) + (= code :invalid-sso-config) + ;; SSO error page needs :organization-id to retry + (if (:organization-id error) + (st/async-emit! (rt/assign-exception (assoc error :type :sso-error))) + (st/async-emit! (rt/assign-exception error))) + :else (st/async-emit! (rt/assign-exception error)))) diff --git a/frontend/src/app/main/features.cljs b/frontend/src/app/main/features.cljs index 7e7890c435..ca87fb7e1a 100644 --- a/frontend/src/app/main/features.cljs +++ b/frontend/src/app/main/features.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.features "A thin, frontend centric abstraction layer and collection of diff --git a/frontend/src/app/main/features/pointer_map.cljs b/frontend/src/app/main/features/pointer_map.cljs index 56fbf34799..176cbbb8c3 100644 --- a/frontend/src/app/main/features/pointer_map.cljs +++ b/frontend/src/app/main/features/pointer_map.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.features.pointer-map "A frontend specific helpers for work with pointer-map feature" diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index 677f8aa1fb..4af7f46ccc 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -2,14 +2,14 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.fonts "Fonts management and loading logic." - (:require-macros [app.main.fonts :refer [preload-gfonts]]) (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.fonts :as cfnt] [app.common.logging :as log] [app.common.types.text :as txt] [app.common.uri :as u] @@ -18,6 +18,7 @@ [app.util.globals :as globals] [app.util.http :as http] [app.util.object :as obj] + [app.util.timers :as tm] [beicon.v2.core :as rx] [cuerdas.core :as str] [okulary.core :as l] @@ -25,27 +26,6 @@ (log/set-level! :warn) -(def google-fonts - (preload-gfonts "fonts/gfonts.2025.11.28.json")) - -(def local-fonts - [{:id "sourcesanspro" - :name "Source Sans Pro" - :family "sourcesanspro" - :variants - [{:id "200" :name "200" :weight "200" :style "normal" :suffix "extralight" :ttf-url "sourcesanspro-extralight.ttf"} - {:id "200italic" :name "200 Italic" :weight "200" :style "italic" :suffix "extralightitalic" :ttf-url "sourcesanspro-extralightitalic.ttf"} - {:id "300" :name "300" :weight "300" :style "normal" :suffix "light" :ttf-url "sourcesanspro-light.ttf"} - {:id "300italic" :name "300 Italic" :weight "300" :style "italic" :suffix "lightitalic" :ttf-url "sourcesanspro-lightitalic.ttf"} - {:id "regular" :name "400" :weight "400" :style "normal" :ttf-url "sourcesanspro-regular.ttf"} - {:id "italic" :name "400 Italic" :weight "400" :style "italic" :ttf-url "sourcesanspro-italic.ttf"} - {:id "600" :name "600" :weight "600" :style "normal" :suffix "semibold" :ttf-url "sourcesanspro-semibold.ttf"} - {:id "600italic" :name "600 Italic" :weight "600" :style "italic" :suffix "semibolditalic" :ttf-url "sourcesanspro-semibolditalic.ttf"} - {:id "bold" :name "700" :weight "700" :style "normal" :ttf-url "sourcesanspro-bold.ttf"} - {:id "bolditalic" :name "700 Italic" :weight "700" :style "italic" :ttf-url "sourcesanspro-bolditalic.ttf"} - {:id "black" :name "900" :weight "900" :style "normal" :ttf-url "sourcesanspro-black.ttf"} - {:id "blackitalic" :name "900 Italic" :weight "900" :style "italic" :ttf-url "sourcesanspro-blackitalic.ttf"}]}]) - (defonce fontsdb (l/atom {})) (defonce fonts (l/atom [])) @@ -65,10 +45,10 @@ fonts (map #(assoc % :backend backend) fonts)] (merge db (d/index-by :id fonts)))))) -(register! :builtin local-fonts) +(register! :builtin cfnt/local-fonts) (when (contains? cf/flags :google-fonts-provider) - (register! :google google-fonts)) + (register! :google cfnt/catalog)) (defn get-font-data [id] (get @fontsdb id)) @@ -137,10 +117,11 @@ ;; uploads, ones that fail to bake) use the runtime fallback. ;; ;; The sprite is heavy (~2000 nodes), so we DON'T keep it in the DOM: the fetched -;; markup is cached here as a string (`:svg`) and the nodes are materialized only -;; while the picker is open (attach/detach below). `:ids` are the font ids it -;; covers, so the UI can pick sprite vs fallback. -(defonce preview-sprite (l/atom {:status :idle :ids #{} :svg nil})) +;; markup is parsed once eagerly into a cached node (`:node`) so attaching is a +;; cheap appendChild. `:ids` are the font ids it covers (also pre-computed), so +;; the UI can pick sprite vs fallback. `:refs` counts open dropdowns sharing the +;; node, so the last one to close is the one that detaches it. +(defonce preview-sprite (l/atom {:status :idle :ids #{} :node nil :refs 0})) ;; Id prefix shared with the generator and the UI's `<use href>`; referenced here ;; rather than re-declared so the contract stays in one place. @@ -162,7 +143,7 @@ [] ;; :error → the UI shows plain names (no previews, no per-font load storm); a ;; later `prefetch-preview-sprite!` call can retry. - (reset! preview-sprite {:status :error :ids #{} :svg nil})) + (reset! preview-sprite {:status :error :ids #{} :node nil :refs 0})) (defn- parse-sprite-svg "Parse the cached sprite markup as SVG (not HTML, so no innerHTML injection @@ -176,10 +157,10 @@ root))) (defn prefetch-preview-sprite! - "Fetch the font-preview sprite markup and cache it in memory (no DOM yet — see - `attach-preview-sprite!`). Idempotent: fetches only when nothing is cached yet - (`:idle`) or a previous attempt failed (`:error`); no-op while `:loading` or - `:ready`." + "Fetch the font-preview sprite markup, pre-parse it on idle, and cache the + parsed DOM node with the font ids it covers. Idempotent: fetches only when + nothing is cached yet (`:idle`) or a previous attempt failed (`:error`); no-op + while `:loading` or `:ready`." [] (when (and (globals/browser?) (contains? #{:idle :error} (:status @preview-sprite))) @@ -191,9 +172,24 @@ (rx/subs! (fn [response] ;; http/send! doesn't reject on non-2xx; guard so an error body isn't - ;; cached as the sprite. + ;; cached as the sprite. The parse is deferred to idle so the + ;; ~2000-node import doesn't spike the main thread at load time; + ;; `:status` stays `:loading` until it's done. (if (http/success? response) - (swap! preview-sprite assoc :status :ready :svg (:body response)) + (let [svg (:body response)] + (tm/schedule-on-idle + (fn [] + (if-let [node (some-> (parse-sprite-svg svg) (dom/import-node))] + (do + (dom/set-attribute! node "id" "font-preview-sprite") + (let [ids (collect-preview-ids node)] + (swap! preview-sprite assoc + :status :ready + :node node + :ids ids))) + (do + (log/wrn :hint "cannot parse font preview sprite") + (reset-preview-sprite-error!)))))) (do (log/wrn :hint "cannot load font preview sprite" :status (:status response)) (reset-preview-sprite-error!)))) @@ -202,34 +198,30 @@ (reset-preview-sprite-error!)))))) (defn attach-preview-sprite! - "Materialize the cached sprite into the DOM (hidden) so rows can reference its - glyph groups via `<use>`, and record the covered font ids. Returns the injected - node (pass it to `detach-preview-sprite!` on close), or nil if not ready / the - markup is invalid. Parsing happens here, not on prefetch, so the cost is paid - only while the picker is open." + "Append the pre-parsed sprite node into the DOM (hidden) so rows can reference + its glyph groups via `<use>`. Returns the node (pass it to + `detach-preview-sprite!` on close), or nil if not ready. Parsing and id + collection happen once during `prefetch-preview-sprite!`, so this is just a + cheap appendChild. Multiple dropdowns may share the node; each attach + increments `:refs` so the node is only detached when the last one closes." [] - (let [{:keys [status svg]} @preview-sprite] - (when (and (globals/browser?) (= :ready status) (some? svg)) - (if-let [node (some-> (parse-sprite-svg svg) (dom/import-node))] - ;; The node already carries display:none + aria-hidden from the generator. - (do - (dom/set-attribute! node "id" "font-preview-sprite") - (when-let [body-el (unchecked-get globals/document "body")] - (dom/append-child! body-el node)) - (swap! preview-sprite assoc :ids (collect-preview-ids node)) - node) - (do - (log/wrn :hint "cannot parse font preview sprite") - (reset-preview-sprite-error!) - nil))))) + (let [{:keys [status node]} @preview-sprite] + (when (and (globals/browser?) (= :ready status) (some? node)) + (when-let [body-el (unchecked-get globals/document "body")] + (dom/append-child! body-el node)) + (swap! preview-sprite update :refs inc) + node))) (defn detach-preview-sprite! - "Remove the sprite node injected by `attach-preview-sprite!` from the DOM. The - cached markup and `:ids` stay, so reopening re-attaches without a refetch." + "Remove the sprite node injected by `attach-preview-sprite!` from the DOM when + the last open dropdown closes. The cached node and `:ids` stay, so reopening + re-attaches without a refetch or re-parse." [node] - (dom/remove! node)) + (let [new-state (swap! preview-sprite update :refs #(max 0 (dec %)))] + (when (zero? (:refs new-state)) + (dom/remove! node)))) -(defn- add-font-css! +(defn- add-font-css "Creates a style element and attaches it to the dom." [id css] (let [node (dom/create-element "style")] @@ -243,8 +235,10 @@ (defmulti ^:private load-font :backend) (defmethod load-font :default - [{:keys [backend] :as font}] - (log/wrn :msg "no implementation found for" :backend backend)) + [{:keys [backend ::on-failed] :as font}] + (log/wrn :msg "no implementation found for" :backend backend) + (when (fn? on-failed) + (on-failed (ex-info "unsupported font backend" {:backend backend})))) (defmethod load-font :builtin [{:keys [id ::on-loaded] :as font}] @@ -266,26 +260,32 @@ (defn- process-gfont-css [css] - (let [base (u/join cf/public-uri "internal/gfonts/font")] - (str/replace css "https://fonts.gstatic.com/s" (dm/str base)))) + (cfnt/gstatic->proxy-url css (u/join cf/public-uri "internal/gfonts/font"))) + +(defn- request-gfont-css + [url] + (->> (http/send! {:method :get :uri url :mode :cors :response-type :text}) + (rx/map :body))) (defn- fetch-gfont-css [url] - (->> (http/send! {:method :get :uri url :mode :cors :response-type :text}) - (rx/map :body) - (rx/catch (fn [err] - (log/wrn :hint "cannot find the font" :cause err) + (->> (request-gfont-css url) + (rx/catch (fn [cause] + ;; Keep CSS streams alive when a font cannot load. + (log/wrn :hint "cannot find the font" :cause cause) (rx/empty))))) (defmethod load-font :google - [{:keys [id ::on-loaded] :as font}] + [{:keys [id ::on-loaded ::on-failed] :as font}] (when (globals/browser?) (log/dbg :hint "load-font" :font-id id :backend "google") (let [url (generate-gfonts-url font)] - (->> (fetch-gfont-css url) + ;; Keep raw errors so the loader can use its fallback. + (->> (request-gfont-css url) (rx/map process-gfont-css) (rx/tap #(on-loaded id)) - (rx/subs! (partial add-font-css! id))) + (rx/subs! (partial add-font-css id) + #(when (fn? on-failed) (on-failed %)))) nil))) ;; --- LOADER: CUSTOM @@ -324,7 +324,7 @@ (when (globals/browser?) (log/dbg :hint "load-font" :font-id id :backend "custom") (let [css (generate-custom-font-css font)] - (add-font-css! id css) + (add-font-css id css) (when (fn? on-loaded) (on-loaded))))) @@ -358,15 +358,30 @@ ;; First caller, we create the promise and then wait :else - (let [on-load (fn [resolve] - (swap! loaded conj font-id) - (swap! loading dissoc font-id) - (resolve font-id)) + (let [settle! (fn [resolve loaded?] + ;; Defer cleanup until a synchronous load is cached. + (tm/schedule + #(do + (when loaded? + (swap! loaded conj font-id)) + (swap! loading dissoc font-id) + (resolve font-id)))) + + on-load (fn [resolve] + (settle! resolve true)) + + on-failed + (fn [resolve cause] + (log/wrn :hint "font load failed; using fallback" + :font-id font-id + :cause cause) + (settle! resolve false)) load-p (-> (p/create (fn [resolve _] (-> font (assoc ::on-loaded (partial on-load resolve)) + (assoc ::on-failed (partial on-failed resolve)) (load-font)))) ;; We need to wait for the font to be loaded (p/then (partial p/delay 120)))] @@ -397,42 +412,12 @@ (defn find-closest-variant "Find the closest font weight variant in `font` for `target-weight` with optional `target-style` match. - When exactly between two weights, choose the higher one." + When exactly between two weights, choose the higher one. + + The algorithm lives in `app.common.fonts` so the headless exporter resolves the + same variant for the same text." [font target-weight target-style] - (when-let [target-weight (d/parse-integer target-weight)] - (let [variants (:variants font []) - result - (reduce - (fn [closest-match variant] - (let [weight (d/parse-integer (:weight variant)) - distance (abs (- target-weight weight)) - matches-style? (= target-style (:style variant)) - current {:variant variant - :weight weight - :distance distance}] - (cond - ;; Exact match found - (and (zero? distance) - (if target-style matches-style? true)) - (reduced current) - - (nil? closest-match) current - - ;; Update best match if this variant is closer or equal distance but higher weight - (or (< distance (:distance closest-match)) - (and (= distance (:distance closest-match)) - (> weight (:weight closest-match)))) - current - - ;; Same weight as the `closest-match` but the style matches `target-style` - (and (= weight (:weight closest-match)) matches-style?) - current - - :else - closest-match))) - nil - variants)] - (:variant result)))) + (cfnt/closest-variant (:variants font []) target-weight target-style)) ;; Font embedding functions (defn get-node-fonts diff --git a/frontend/src/app/main/rasterizer.cljs b/frontend/src/app/main/rasterizer.cljs index 066b845344..9e32471c38 100644 --- a/frontend/src/app/main/rasterizer.cljs +++ b/frontend/src/app/main/rasterizer.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.rasterizer "A main entry point for the rasterizer API interface. diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index a73ae9b872..064e7cd4e8 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.refs "A collection of derived refs." @@ -222,6 +222,9 @@ (def selected-edition (l/derived :edition workspace-local)) +(def workspace-edit-path + (l/derived :edit-path workspace-local)) + (def current-transform (l/derived :transform workspace-local)) diff --git a/frontend/src/app/main/render.cljs b/frontend/src/app/main/render.cljs index 0655545429..5c185f83c4 100644 --- a/frontend/src/app/main/render.cljs +++ b/frontend/src/app/main/render.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.render "Rendering utilities and components for penpot SVG. @@ -15,6 +15,7 @@ ["react-dom/server" :as rds] [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.exceptions :as ex] [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] @@ -59,6 +60,7 @@ [rumext.v2 :as mf])) (def ^:const viewbox-decimal-precision 3) +(def ^:const max-export-dimension 100000) (def ^:private default-color clr/canvas) (mf/defc background @@ -82,12 +84,20 @@ (let [bounds (->> root-objects (map (partial gsb/get-object-bounds objects)) - (grc/join-rects))] + (grc/join-rects)) + bounds (-> bounds + (update :x mth/finite 0) + (update :y mth/finite 0) + (update :width mth/finite 100000) + (update :height mth/finite 100000))] + (when (or (> (:width bounds) max-export-dimension) + (> (:height bounds) max-export-dimension) + (> (+ (:x bounds) (:width bounds)) max-export-dimension) + (> (+ (:y bounds) (:height bounds)) max-export-dimension)) + (ex/raise :type :validation + :code :export-area-too-large + :hint "export area exceeds maximum allowed dimensions")) (-> bounds - (update :x mth/finite 0) - (update :y mth/finite 0) - (update :width mth/finite 100000) - (update :height mth/finite 100000) (grc/update-rect :position) (grc/fix-aspect-ratio aspect-ratio)))))) diff --git a/frontend/src/app/main/render_viewer_wasm.cljs b/frontend/src/app/main/render_viewer_wasm.cljs index 45f7b83f73..9a6bca0d99 100644 --- a/frontend/src/app/main/render_viewer_wasm.cljs +++ b/frontend/src/app/main/render_viewer_wasm.cljs @@ -2,15 +2,15 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.render-viewer-wasm "WASM offscreen rendering for the shared viewer (snapshot + fixed-scroll)." (:require [app.common.data.macros :as dm] [app.common.exceptions :as ex] + [app.common.render-wasm.wasm :as wasm] [app.render-wasm.api :as wasm.api] - [app.render-wasm.wasm :as wasm] [app.util.dom :as dom] [app.util.timers :as ts] [app.util.webapi :as webapi] diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index dcfac7bc22..68749a7f45 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.repo (:require @@ -276,6 +276,28 @@ (let [default {:wait false :blob? false}] (send-export (merge default params)))) +(defmethod cmd! :create-export-job + [_ params] + (->> (http/send! {:method :post + :uri (u/join cf/public-uri "api/export/jobs") + :body (http/transit-data params) + :headers {"x-external-session-id" (cf/external-session-id) + "x-event-origin" (::ev/origin (meta params))} + :credentials "include" + :response-type :text}) + (rx/map http/conditional-decode-transit) + (rx/mapcat handle-response))) + +(defmethod cmd! :cancel-export-job + [_ {:keys [job-id]}] + (->> (http/send! {:method :delete + :uri (u/join cf/public-uri "api/export/jobs/" (str job-id)) + :headers {"x-external-session-id" (cf/external-session-id)} + :credentials "include" + :response-type :text}) + (rx/map http/conditional-decode-transit) + (rx/mapcat handle-response))) + (defn- multipart-upload [id params] (->> (http/send! {:method :post diff --git a/frontend/src/app/main/router.cljs b/frontend/src/app/main/router.cljs index ff7e6abbbd..f95f5f521e 100644 --- a/frontend/src/app/main/router.cljs +++ b/frontend/src/app/main/router.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.router (:refer-clojure :exclude [resolve]) diff --git a/frontend/src/app/main/snap.cljs b/frontend/src/app/main/snap.cljs index bf68dcae5f..c67cddf054 100644 --- a/frontend/src/app/main/snap.cljs +++ b/frontend/src/app/main/snap.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.snap (:require diff --git a/frontend/src/app/main/store.cljs b/frontend/src/app/main/store.cljs index 8bdff34a65..8a0d330ca5 100644 --- a/frontend/src/app/main/store.cljs +++ b/frontend/src/app/main/store.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.store (:require diff --git a/frontend/src/app/main/streams.cljs b/frontend/src/app/main/streams.cljs index a2fce0a4e6..be71c210ff 100644 --- a/frontend/src/app/main/streams.cljs +++ b/frontend/src/app/main/streams.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.streams "User interaction events and streams." diff --git a/frontend/src/app/main/style.clj b/frontend/src/app/main/style.clj index 19282d71b1..d98f251e24 100644 --- a/frontend/src/app/main/style.clj +++ b/frontend/src/app/main/style.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.style "A fonts loading macros." diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 58175de604..cb7fe8c87a 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui (:require diff --git a/frontend/src/app/main/ui/alert.cljs b/frontend/src/app/main/ui/alert.cljs index 46e825a078..8804a8f6ff 100644 --- a/frontend/src/app/main/ui/alert.cljs +++ b/frontend/src/app/main/ui/alert.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.alert (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/alert.scss b/frontend/src/app/main/ui/alert.scss index cf4b9e6c37..53165b8d7c 100644 --- a/frontend/src/app/main/ui/alert.scss +++ b/frontend/src/app/main/ui/alert.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/auth.cljs b/frontend/src/app/main/ui/auth.cljs index 790d8b0a3e..9ec094716d 100644 --- a/frontend/src/app/main/ui/auth.cljs +++ b/frontend/src/app/main/ui/auth.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.auth (:require-macros [app.main.style :as stl]) @@ -13,7 +13,7 @@ [app.main.ui.auth.login :refer [login-page*]] [app.main.ui.auth.recovery :refer [recovery-page*]] [app.main.ui.auth.recovery-request :refer [recovery-request-page*]] - [app.main.ui.auth.register :refer [register-page* register-success-page* register-validate-page* terms-register*]] + [app.main.ui.auth.register :refer [register-page* register-success-page* register-validate-page* terms-service-privacy-policy*]] [app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*] :as raw-svg] [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.util.dom :as dom] @@ -74,7 +74,7 @@ [:> recovery-page* {:params params}]) (when (= section :auth-register) - [:> terms-register*])]])) + [:> terms-service-privacy-policy*])]])) (mf/defc auth-page* diff --git a/frontend/src/app/main/ui/auth.scss b/frontend/src/app/main/ui/auth.scss index 7d593ce4fa..cc5c4d1cd2 100644 --- a/frontend/src/app/main/ui/auth.scss +++ b/frontend/src/app/main/ui/auth.scss @@ -2,10 +2,11 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; +@use "./ds/mixins.scss" as *; .auth-section { display: grid; @@ -26,6 +27,8 @@ } .auth-section.register { + @include custom-scrollbar; + display: flex; justify-content: center; align-items: center; diff --git a/frontend/src/app/main/ui/auth/common.scss b/frontend/src/app/main/ui/auth/common.scss index 951ea442a6..c49311c1ba 100644 --- a/frontend/src/app/main/ui/auth/common.scss +++ b/frontend/src/app/main/ui/auth/common.scss @@ -2,58 +2,17 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL -@use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; @use "ds/typography.scss" as *; -.auth-form-wrapper { - inline-size: 100%; - padding-block-end: 0; - display: grid; +.form { + display: flex; + flex-direction: column; gap: var(--sp-m); - - // Native <form> inside auth-form-wrapper — no class available - form { - display: flex; - flex-direction: column; - gap: var(--sp-m); - margin-block-start: var(--sp-m); - } -} - -.auth-title-wrapper { - inline-size: 100%; - padding-block-end: 0; - display: grid; - gap: var(--sp-s); -} - -.separator { - border-color: var(--color-background-quaternary); - margin: 0; -} - -.auth-title { - @include use-typography("title-large"); - - line-height: 1.2; - color: var(--color-foreground-primary); -} - -.auth-subtitle { - @include use-typography("title-small"); - - color: var(--color-foreground-secondary); -} - -.auth-tagline { - @include use-typography("title-small"); - - margin: 0; - color: var(--color-foreground-secondary); + margin: var(--sp-m) 0; } .form-field { @@ -62,36 +21,40 @@ --input-min-width: 100%; } -.buttons-stack { - display: grid; - gap: var(--sp-s); -} +.form-submit-btn { + --button-bg-color: var(--color-accent-primary); + --button-border-color: var(--color-accent-primary); + --button-fg-color: var(--color-background-secondary); -.login-button, -.login-ldap-button { @include use-typography("headline-small"); display: flex; justify-content: center; align-items: center; - cursor: pointer; - background-color: var(--color-accent-primary); - border: $b-1 solid var(--color-accent-primary); - color: var(--color-background-secondary); + background-color: var(--button-bg-color); + border: $b-1 solid var(--button-border-color); + color: var(--button-fg-color); border-radius: $br-8; min-block-size: $sz-32; block-size: $sz-40; inline-size: 100%; + &:hover { + --button-bg-color: var(--color-accent-tertiary); + --button-border-color: var(--color-accent-tertiary); + --button-fg-color: var(--color-background-secondary); + + text-decoration: none; + } + &:disabled { - background-color: var(--color-background-quaternary); - border: 1px solid var(--color-background-quaternary); - color: var(--color-foreground-disabled); - cursor: unset; + --button-bg-color: var(--color-background-quaternary); + --button-border-color: var(--color-background-quaternary); + --button-fg-color: var(--color-foreground-disabled); } } -.go-back { +.go-back-row { display: flex; flex-direction: column; gap: var(--sp-m); @@ -100,19 +63,68 @@ } .go-back-link { + --button-bg-color: var(--color-background-tertiary); + --button-border-color: var(--color-background-tertiary); + --button-fg-color: var(--color-foreground-secondary); + + @include use-typography("headline-small"); + background: none; - cursor: pointer; display: flex; justify-content: center; align-items: center; border-radius: $br-8; - background-color: var(--color-background-tertiary); - border: $b-1 solid var(--color-background-tertiary); - color: var(--color-foreground-secondary); - - @include use-typography("headline-small"); - + background-color: var(--button-bg-color); + border: $b-1 solid var(--button-border-color); + color: var(--button-fg-color); block-size: $sz-40; + + &:hover { + --button-bg-color: var(--color-background-quaternary); + --button-border-color: var(--color-background-quaternary); + --button-fg-color: var(--color-accent-primary); + + text-decoration: none; + } +} + +.separator { + border-color: var(--color-background-quaternary); + margin: 0; +} + +.wrapper { + inline-size: 100%; + padding-block-end: 0; + display: grid; + gap: var(--sp-m); +} + +.title-wrapper { + inline-size: 100%; + padding-block-end: 0; + display: grid; + gap: var(--sp-s); +} + +.title { + @include use-typography("title-large"); + + line-height: 1.2; + color: var(--color-foreground-primary); +} + +.subtitle { + @include use-typography("title-small"); + + color: var(--color-foreground-secondary); +} + +.tagline { + @include use-typography("title-small"); + + margin: 0; + color: var(--color-foreground-secondary); } .links { @@ -120,104 +132,10 @@ gap: var(--sp-xxl); } -.register, -.account, -.recovery-request, -.demo-account { - display: flex; - justify-content: center; - gap: var(--sp-s); - padding: 0; -} +.notification-email { + @include use-typography("title-medium"); -.register-text, -.account-text, -.recovery-text, -.demo-account-text { - @include use-typography("title-small"); - - text-align: right; - color: var(--color-foreground-secondary); -} - -.register-link, -.account-link, -.recovery-link, -.forgot-pass-link, -.demo-account-link { - @include use-typography("title-small"); - - text-align: left; - background-color: transparent; - border: none; - display: inline; + line-height: 1.2; color: var(--color-accent-primary); - - &:hover { - text-decoration: underline; - } -} - -.forgot-password { - display: flex; - justify-content: flex-end; -} - -.submit-btn, -.register-btn, -.recover-btn { - @include use-typography("headline-small"); - - background: none; - cursor: pointer; - display: flex; - justify-content: center; - align-items: center; - background-color: var(--color-accent-primary); - border: $b-1 solid var(--color-accent-primary); - color: var(--color-background-secondary); - border-radius: $br-8; - min-block-size: $sz-32; - block-size: $sz-40; - inline-size: 100%; - - &:disabled { - background-color: var(--color-background-quaternary); - border: $b-1 solid var(--color-background-quaternary); - color: var(--color-foreground-disabled); - cursor: unset; - } -} - -.login-btn { - @include use-typography("title-small"); - - display: flex; - align-items: center; - gap: px2rem(6); - inline-size: 100%; - border-radius: $br-8; - background-color: var(--color-background-tertiary); - color: var(--color-foreground-primary); - - span { - padding-block-start: var(--sp-xxs); - } - - &:hover { - color: var(--color-foreground-primary); - background-color: var(--color-background-quaternary); - } - - &:disabled { - background-color: var(--color-background-quaternary); - border: 1px solid var(--color-background-quaternary); - color: var(--color-foreground-disabled); - cursor: unset; - } -} - -.auth-buttons { - display: flex; - gap: var(--sp-s); + margin-inline: $sz-36; } diff --git a/frontend/src/app/main/ui/auth/login.cljs b/frontend/src/app/main/ui/auth/login.cljs index c23cbf1c53..71fd5d4108 100644 --- a/frontend/src/app/main/ui/auth/login.cljs +++ b/frontend/src/app/main/ui/auth/login.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.auth.login (:require-macros [app.main.style :as stl]) @@ -71,10 +71,17 @@ (mf/defc login-form* [{:keys [params handle-redirect on-success-callback on-recovery-request origin] :as props}] (let [initial (mf/with-memo [params] params) - error (mf/use-state false) + form (fm/use-form :schema schema:login-form :initial initial) + + error (mf/use-state false) + + show-password-field* (mf/use-state #(not (contains? cf/flags :login-with-custom-sso))) + show-password-field? (deref show-password-field*) + callback-url (:callback-url params) + on-error (fn [cause] (let [cause (ex-data cause)] @@ -102,12 +109,6 @@ :else (reset! error (tr "errors.generic"))))) - show-password-field* - (mf/use-state #(not (contains? cf/flags :login-with-custom-sso))) - - show-password-field? - (deref show-password-field*) - on-success (fn [data] (when (fn? on-success-callback) @@ -130,7 +131,8 @@ (->> (rp/cmd! :get-sso-provider {:email (:email params)}) (rx/map :id) (rx/catch (fn [cause] - (log/error :hint "error on retrieving sso provider" :cause cause) + (log/error :hint "error on retrieving sso provider" + :cause cause) (rx/of nil))) (rx/subs! (fn [sso-provider-id] (if sso-provider-id @@ -139,7 +141,7 @@ (reset! show-password-field* true)))))))))) on-submit-ldap - (mf/use-callback + (mf/use-fn (mf/deps form) (fn [event] (dom/prevent-default event) @@ -168,91 +170,102 @@ {:level :error} message]) [:& fm/form {:on-submit on-submit - :class (stl/css :login-form) + :class (stl/css :form) :form form} - [:div {:class (stl/css :fields-row)} - [:& fm/input - {:name :email - :type "email" - :label (tr "auth.work-email") - :class (stl/css :form-field)}]] + [:div {:class (stl/css :form-row)} + [:& fm/input {:name :email + :type "email" + :label (tr "auth.work-email") + :class (stl/css :form-field)}]] (when show-password-field? - [:div {:class (stl/css :fields-row)} - [:& fm/input - {:type "password" - :name :password - :auto-focus? true - :label (tr "auth.password") - :class (stl/css :form-field)}]]) + [:div {:class (stl/css :form-row)} + [:& fm/input {:type "password" + :name :password + :auto-focus? true + :label (tr "auth.password") + :class (stl/css :form-field)}]]) (when (and (not= origin :viewer) (or (contains? cf/flags :login) (contains? cf/flags :login-with-password))) - [:div {:class (stl/css :fields-row :forgot-password)} + [:div {:class (stl/css :form-row :forgot-password-row)} [:> lk/link* {:action on-recovery-request - :class (stl/css :forgot-pass-link) + :class (stl/css :forgot-password-link) :data-testid "forgot-password"} (tr "auth.forgot-password")]]) - [:div {:class (stl/css :buttons-stack)} + [:div {:class (stl/css :form-submit-buttons)} (when (or (contains? cf/flags :login) (contains? cf/flags :login-with-password)) - [:> fm/submit-button* - {:label (tr "labels.continue") - :data-testid "login-submit" - :class (stl/css :login-button)}]) + [:> fm/submit-button* {:label (tr "labels.continue") + :data-testid "login-submit" + :class (stl/css :form-submit-btn)}]) (when (contains? cf/flags :login-with-ldap) - [:> fm/submit-button* - {:label (tr "auth.login-with-ldap-submit") - :class (stl/css :login-ldap-button) - :on-click on-submit-ldap}])]]])) + [:> fm/submit-button* {:label (tr "auth.login-with-ldap-submit") + :class (stl/css :form-submit-btn) + :on-click on-submit-ldap}])]]])) (defn raw-icon [id] (mf/html [:> raw-svg* {:id id :class (stl/css :sso-icon)}])) -(mf/defc login-sso-buttons* +(mf/defc sso-buttons* [{:keys [params] :as props}] - (let [login-with-google (mf/use-fn (mf/deps params) #(login-with-sso "google" params)) - login-with-github (mf/use-fn (mf/deps params) #(login-with-sso "github" params)) - login-with-gitlab (mf/use-fn (mf/deps params) #(login-with-sso "gitlab" params)) - login-with-oidc (mf/use-fn (mf/deps params) #(login-with-sso "oidc" params))] + (let [login-with-google + (mf/use-fn + (mf/deps params) + #(login-with-sso "google" params)) - [:div {:class (stl/css :auth-buttons)} + login-with-github + (mf/use-fn + (mf/deps params) + #(login-with-sso "github" params)) + + login-with-gitlab + (mf/use-fn + (mf/deps params) + #(login-with-sso "gitlab" params)) + + login-with-oidc + (mf/use-fn + (mf/deps params) + #(login-with-sso "oidc" params))] + + [:div {:class (stl/css :sso-row)} (when (contains? cf/flags :login-with-google) [:> bl/button-link* {:on-click login-with-google :icon (raw-icon raw-icons/brand-google) :label (tr "auth.login-with-google-submit") - :class (stl/css :login-btn :btn-google-auth)}]) + :class (stl/css :sso-btn)}]) (when (contains? cf/flags :login-with-github) [:> bl/button-link* {:on-click login-with-github :icon (raw-icon raw-icons/brand-github) :label (tr "auth.login-with-github-submit") - :class (stl/css :login-btn :btn-github-auth)}]) + :class (stl/css :sso-btn)}]) (when (contains? cf/flags :login-with-gitlab) [:> bl/button-link* {:on-click login-with-gitlab :icon (raw-icon raw-icons/brand-gitlab) :label (tr "auth.login-with-gitlab-submit") - :class (stl/css :login-btn :btn-gitlab-auth)}]) + :class (stl/css :sso-btn)}]) (when (contains? cf/flags :login-with-oidc) [:> bl/button-link* {:on-click login-with-oidc :icon (raw-icon raw-icons/brand-openid) :label (or (not-empty cf/oidc-name) (tr "auth.login-with-oidc-submit")) - :class (stl/css :login-btn :btn-oidc-auth)}])])) + :class (stl/css :sso-btn)}])])) (mf/defc login-dialog* [{:keys [params] :as props}] [:* (when show-sso-login-buttons? [:* - [:> login-sso-buttons* {:params params}] + [:> sso-buttons* {:params params}] (when (or (contains? cf/flags :login) (contains? cf/flags :login-with-password) @@ -270,11 +283,11 @@ (mf/use-fn #(st/emit! (rt/nav :auth-register params)))] - [:div {:class (stl/css :auth-form-wrapper)} - [:h1 {:class (stl/css :auth-title) + [:div {:class (stl/css :wrapper)} + [:h1 {:class (stl/css :title) :data-testid "login-title"} (tr "auth.login-account-title")] - [:p {:class (stl/css :auth-tagline)} + [:p {:class (stl/css :tagline)} (tr "auth.login-tagline")] (when (contains? cf/flags :demo-warning) @@ -286,7 +299,7 @@ [:div {:class (stl/css :links)} (when (contains? cf/flags :registration) - [:div {:class (stl/css :register)} + [:div {:class (stl/css :register-row)} [:span {:class (stl/css :register-text)} (tr "auth.register") " "] [:> lk/link* {:action go-register diff --git a/frontend/src/app/main/ui/auth/login.scss b/frontend/src/app/main/ui/auth/login.scss index 5aa37946bb..32a7e82e3f 100644 --- a/frontend/src/app/main/ui/auth/login.scss +++ b/frontend/src/app/main/ui/auth/login.scss @@ -2,12 +2,92 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "./common"; @use "ds/_utils.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/typography.scss" as *; + +.forgot-password-row { + display: flex; + justify-content: flex-end; +} + +.forgot-password-link { + @include use-typography("title-small"); + + text-align: left; + background-color: transparent; + border: none; + display: inline; + color: var(--color-accent-primary); + + &:hover { + text-decoration: underline; + } +} + +.form-submit-buttons { + display: grid; + gap: var(--sp-s); +} .sso-icon { max-inline-size: px2rem(26); max-block-size: px2rem(26); } + +.sso-row { + display: flex; + gap: var(--sp-s); +} + +.sso-btn { + --button-bg-color: var(--color-background-tertiary); + --button-fg-color: var(--color-foreground-primary); + + @include use-typography("title-small"); + + display: flex; + align-items: center; + gap: px2rem(6); + inline-size: 100%; + border-radius: $br-8; + background-color: var(--button-bg-color); + color: var(--button-fg-color); + + &:hover { + --button-bg-color: var(--color-background-quaternary); + --button-fg-color: var(--color-foreground-primary); + } +} + +.register-row { + display: flex; + justify-content: center; + gap: var(--sp-s); + padding: 0; +} + +.register-text { + @include use-typography("title-small"); + + text-align: right; + color: var(--color-foreground-secondary); +} + +.register-link { + @include use-typography("title-small"); + + text-align: left; + background-color: transparent; + border: none; + display: inline; + color: var(--color-accent-primary); + + &:hover { + text-decoration: underline; + } +} diff --git a/frontend/src/app/main/ui/auth/recovery.cljs b/frontend/src/app/main/ui/auth/recovery.cljs index 5ee49525db..80410eb8cd 100644 --- a/frontend/src/app/main/ui/auth/recovery.cljs +++ b/frontend/src/app/main/ui/auth/recovery.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.auth.recovery (:require-macros [app.main.style :as stl]) @@ -28,8 +28,18 @@ (= password-1 password-2))]]) (defn- on-error - [_form _error] - (st/emit! (ntf/error (tr "errors.invalid-recovery-token")))) + [form error] + (let [{:keys [type code] :as edata} (ex-data error)] + (if (= [:validation :weak-password] [type code]) + (let [details (:details edata) + options (when (seq details) + (mapv tr details))] + (swap! form assoc-in [:extra-errors :password-1] + {:message (tr "errors.weak-password") + :options options})) + + (let [msg (tr "errors.invalid-recovery-token")] + (st/emit! (ntf/error msg)))))) (defn- on-success [_] @@ -38,7 +48,7 @@ (defn- on-submit [form _event] - (let [mdata {:on-error on-error + (let [mdata {:on-error (partial on-error form) :on-success on-success} params {:token (get-in @form [:clean-data :token]) :password (get-in @form [:clean-data :password-2])}] @@ -50,39 +60,38 @@ :initial params)] [:& fm/form {:on-submit on-submit - :class (stl/css :recovery-form) + :class (stl/css :form) :form form} - [:div {:class (stl/css :fields-row)} + [:div {:class (stl/css :form-row)} [:& fm/input {:type "password" :name :password-1 :show-success? true :label (tr "auth.new-password") :class (stl/css :form-field)}]] - [:div {:class (stl/css :fields-row)} + [:div {:class (stl/css :form-row)} [:& fm/input {:type "password" :name :password-2 :show-success? true :label (tr "auth.confirm-password") :class (stl/css :form-field)}]] - [:> fm/submit-button* - {:label (tr "auth.recovery-submit") - :class (stl/css :submit-btn)}]])) + [:> fm/submit-button* {:label (tr "auth.recovery-submit") + :class (stl/css :form-submit-btn)}]])) ;; --- Recovery Request Page (mf/defc recovery-page* [{:keys [params]}] - [:div {:class (stl/css :auth-form-wrapper)} - [:h1 {:class (stl/css :auth-title)} "Forgot your password?"] - [:div {:class (stl/css :auth-subtitle)} "Please enter your new password"] + [:div {:class (stl/css :wrapper)} + [:h1 {:class (stl/css :title)} (tr "auth.recovery-request-title")] + [:div {:class (stl/css :subtitle)} (tr "auth.recovery-request-subtitle")] [:hr {:class (stl/css :separator)}] [:> recovery-form* {:params params}] [:div {:class (stl/css :links)} - [:div {:class (stl/css :go-back)} + [:div {:class (stl/css :go-back-row)} [:a {:on-click #(st/emit! (rt/nav :auth-login)) :class (stl/css :go-back-link)} (tr "profile.recovery.go-to-login")]]]]) diff --git a/frontend/src/app/main/ui/auth/recovery.scss b/frontend/src/app/main/ui/auth/recovery.scss index 4d0d4750bd..4742fc3ccc 100644 --- a/frontend/src/app/main/ui/auth/recovery.scss +++ b/frontend/src/app/main/ui/auth/recovery.scss @@ -2,10 +2,6 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "./common"; - -.submit-btn { - margin-block-start: var(--sp-l); -} diff --git a/frontend/src/app/main/ui/auth/recovery_request.cljs b/frontend/src/app/main/ui/auth/recovery_request.cljs index 78caa421ff..8030269dc2 100644 --- a/frontend/src/app/main/ui/auth/recovery_request.cljs +++ b/frontend/src/app/main/ui/auth/recovery_request.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.auth.recovery-request (:require-macros [app.main.style :as stl]) @@ -70,18 +70,17 @@ (st/emit! (du/request-profile-recovery params)))))] [:& fm/form {:on-submit on-submit - :class (stl/css :recovery-request-form) + :class (stl/css :form) :form form} - [:div {:class (stl/css :fields-row)} + [:div {:class (stl/css :form-row)} [:& fm/input {:name :email :label (tr "auth.work-email") :type "text" :class (stl/css :form-field)}]] - [:> fm/submit-button* - {:label (tr "auth.recovery-request-submit") - :data-testid "recovery-resquest-submit" - :class (stl/css :recover-btn)}]])) + [:> fm/submit-button* {:label (tr "auth.recovery-request-submit") + :data-testid "recovery-resquest-submit" + :class (stl/css :form-submit-btn)}]])) ;; --- Recovery Request Page @@ -90,14 +89,14 @@ [{:keys [params on-success-callback go-back-callback]}] (let [default-go-back #(st/emit! (rt/nav :auth-login)) go-back (or go-back-callback default-go-back)] - [:div {:class (stl/css :auth-form-wrapper)} - [:h1 {:class (stl/css :auth-title)} (tr "auth.recovery-request-title")] - [:div {:class (stl/css :auth-subtitle)} (tr "auth.recovery-request-subtitle")] + [:div {:class (stl/css :wrapper)} + [:h1 {:class (stl/css :title)} (tr "auth.recovery-request-title")] + [:div {:class (stl/css :subtitle)} (tr "auth.recovery-request-subtitle")] [:hr {:class (stl/css :separator)}] [:> recovery-form* {:params params :on-success-callback on-success-callback}] [:hr {:class (stl/css :separator)}] - [:div {:class (stl/css :go-back)} + [:div {:class (stl/css :go-back-row)} [:> lk/link* {:action go-back :class (stl/css :go-back-link) :data-testid "go-back-link"} @@ -106,11 +105,10 @@ (mf/defc recovery-sent-page* [{:keys [email]}] - [:div {:class (stl/css :auth-form-wrapper :register-success)} - [:div {:class (stl/css :auth-title-wrapper)} - [:h2 {:class (stl/css :auth-title)} + [:div {:class (stl/css :wrapper :register-success)} + [:div {:class (stl/css :title-wrapper)} + [:h2 {:class (stl/css :title)} (tr "auth.check-email")] [:div {:class (stl/css :notification-text)} (tr "not-found.login.sent-recovery")]] - [:div {:class (stl/css :notification-text-email)} email] + [:div {:class (stl/css :notification-email)} email] [:div {:class (stl/css :notification-text)} (tr "not-found.login.sent-recovery-check")]]) - diff --git a/frontend/src/app/main/ui/auth/recovery_request.scss b/frontend/src/app/main/ui/auth/recovery_request.scss index 11d45df27b..4742fc3ccc 100644 --- a/frontend/src/app/main/ui/auth/recovery_request.scss +++ b/frontend/src/app/main/ui/auth/recovery_request.scss @@ -2,21 +2,6 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL -@use "ds/_utils.scss" as *; -@use "ds/_sizes.scss" as *; -@use "ds/typography.scss" as *; @use "./common"; - -.fields-row { - margin-block-end: var(--sp-s); -} - -.notification-text-email { - @include use-typography("title-medium"); - - line-height: 1.2; - color: var(--color-accent-primary); - margin-inline: $sz-36; -} diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index bb8966d12b..e6cd8197f5 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.auth.register (:require-macros [app.main.style :as stl]) @@ -21,43 +21,43 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.storage :as storage] [beicon.v2.core :as rx] + [cuerdas.core :as str] [rumext.v2 :as mf])) ;; --- PAGE: Register -(mf/defc newsletter-options* +(mf/defc newsletter-checkbox* {::mf/private true} [] - (let [updates-label + (let [newsletter-label (mf/html - [:> i18n/tr-html* - {:tag-name "div" - :content (tr "onboarding-v2.newsletter.updates")}])] - [:div {:class (stl/css :fields-row :input-visible :newsletter-option-wrapper)} + [:> i18n/tr-html* {:tag-name "div" + :content (tr "onboarding-v2.newsletter.updates")}])] + + [:div {:class (stl/css :form-row :input-visible :newsletter-option-wrapper)} [:& fm/input {:name :accept-newsletter-updates :class (stl/css :checkbox-newsletter-updates) :type "checkbox" :default-checked false - :label updates-label}]])) + :label newsletter-label}]])) -(mf/defc terms-and-privacy* +(mf/defc terms-and-privacy-checkbox* {::mf/private true} [] - (let [terms-label + (let [terms-and-privacy-label (mf/html - [:> i18n/tr-html* - {:tag-name "div" - :content (tr "auth.terms-and-privacy-agreement" - cf/terms-of-service-uri - cf/privacy-policy-uri)}])] + [:> i18n/tr-html* {:tag-name "div" + :content (tr "auth.terms-and-privacy-agreement" + cf/terms-of-service-uri + cf/privacy-policy-uri)}])] - [:div {:class (stl/css :fields-row :input-visible :accept-terms-and-privacy-wrapper)} + [:div {:class (stl/css :form-row :input-visible :accept-terms-and-privacy-wrapper)} [:& fm/input {:name :accept-terms-and-privacy :show-error false :class (stl/css :checkbox-terms-and-privacy) :type "checkbox" :default-checked false - :label terms-label}]])) + :label terms-and-privacy-label}]])) (def ^:private schema:register-form [:map {:title "RegisterForm"} @@ -75,8 +75,7 @@ form (fm/use-form :schema schema:register-form :initial initial) - submitted? - (mf/use-state false) + submitted? (mf/use-state false) on-error (mf/use-fn @@ -103,8 +102,21 @@ (st/emit! (ntf/error (tr "errors.email-already-exists"))) [:validation :email-as-password] - (swap! form assoc-in [:errors :password] - {:message (tr "errors.email-as-password")}) + (st/emit! (ntf/error (tr "errors.email-as-password"))) + + [:validation :weak-password] + (let [details (:details edata) + items (when (seq details) + (->> details + (map #(str "<li>" (tr %) "</li>")) + (str/join ""))) + detail (when items + (str "<ul>" items "</ul>"))] + (st/emit! (ntf/show {:content (tr "errors.weak-password") + :detail detail + :is-html true + :type :toast + :level :error}))) (do (when-let [explain (get edata :explain)] @@ -154,22 +166,24 @@ (->> (rp/cmd! :prepare-register-profile cdata) (rx/subs! on-register-profile on-error #(reset! submitted? false))))))] - [:& fm/form {:on-submit on-submit :form form} - [:div {:class (stl/css :fields-row)} + [:& fm/form {:on-submit on-submit + :form form + :class (stl/css :form)} + [:div {:class (stl/css :form-row)} [:& fm/input {:name :fullname :label (tr "auth.fullname") :type "text" :show-success? true :class (stl/css :form-field)}]] - [:div {:class (stl/css :fields-row)} + [:div {:class (stl/css :form-row)} [:& fm/input {:type "text" :name :email :label (tr "auth.work-email") :data-testid "email-input" :show-success? true :class (stl/css :form-field)}]] - [:div {:class (stl/css :fields-row)} + [:div {:class (stl/css :form-row)} [:& fm/input {:name :password :hint (tr "auth.password-length-hint") :label (tr "auth.password") @@ -178,21 +192,20 @@ :class (stl/css :form-field)}]] (when (contains? cf/flags :terms-and-privacy-checkbox) - [:> terms-and-privacy*]) + [:> terms-and-privacy-checkbox*]) - [:> newsletter-options*] + [:> newsletter-checkbox*] - [:> fm/submit-button* - {:label (tr "auth.register-submit") - :disabled @submitted? - :data-testid "register-form-submit" - :class (stl/css :register-btn)}]])) + [:> fm/submit-button* {:label (tr "auth.register-submit") + :disabled @submitted? + :data-testid "register-form-submit" + :class (stl/css :form-submit-btn)}]])) (mf/defc register-methods* [{:keys [params hide-separator on-success-callback]}] [:* (when login/show-sso-login-buttons? - [:> login/login-sso-buttons* {:params params}]) + [:> login/sso-buttons* {:params params}]) (when (or login/show-sso-login-buttons? (false? hide-separator)) [:hr {:class (stl/css :separator)}]) (when (contains? cf/flags :login-with-password) @@ -200,8 +213,8 @@ (mf/defc register-page* [{:keys [params]}] - [:div {:class (stl/css :auth-form-wrapper :register-form)} - [:h1 {:class (stl/css :auth-title) + [:div {:class (stl/css :wrapper :register-form)} + [:h1 {:class (stl/css :title) :data-testid "registration-title"} (tr "auth.register-title")] (when (contains? cf/flags :demo-warning) @@ -210,7 +223,7 @@ [:> register-methods* {:params params}] [:div {:class (stl/css :links)} - [:div {:class (stl/css :account)} + [:div {:class (stl/css :account-row)} [:span {:class (stl/css :account-text)} (tr "auth.already-have-account") " "] [:> lk/link* {:action #(st/emit! (rt/nav :auth-login params)) :class (stl/css :account-link) @@ -220,9 +233,9 @@ (when (contains? cf/flags :demo-users) [:* [:hr {:class (stl/css :separator)}] - [:div {:class (stl/css :demo-account)} + [:div {:class (stl/css :account-row)} [:> lk/link* {:action login/create-demo-profile - :class (stl/css :demo-account-link)} + :class (stl/css :account-link)} (tr "auth.create-demo-account")]]])]]) @@ -231,31 +244,31 @@ (mf/defc register-success-page* [{:keys [params]}] (let [email (or (:email params) (::email storage/user))] - [:div {:class (stl/css :auth-form-wrapper :register-success)} - [:div {:class (stl/css :auth-title-wrapper)} - [:h2 {:class (stl/css :auth-title)} + [:div {:class (stl/css :wrapper :register-success)} + [:div {:class (stl/css :title-wrapper)} + [:h2 {:class (stl/css :register-success-title)} (tr "auth.check-email")] [:div {:class (stl/css :notification-text)} (tr "auth.verification-sent-email")]] - [:div {:class (stl/css :notification-text-email)} email]])) + [:div {:class (stl/css :notification-email)} email]])) -(mf/defc terms-register* +(mf/defc terms-service-privacy-policy* [] (let [show-all? (and cf/terms-of-service-uri cf/privacy-policy-uri) show-terms? (some? cf/terms-of-service-uri) show-privacy? (some? cf/privacy-policy-uri)] (when show-all? - [:div {:class (stl/css :terms-register)} + [:div {:class (stl/css :terms)} (when show-terms? - [:a {:href cf/terms-of-service-uri :target "_blank" :class (stl/css :auth-link)} + [:a {:href cf/terms-of-service-uri :target "_blank" :class (stl/css :terms-link)} (tr "auth.terms-of-service")]) (when show-all? - [:span {:class (stl/css :and-text)} + [:span {:class (stl/css :terms-and)} (dm/str " " (tr "labels.and") " ")]) (when show-privacy? - [:a {:href cf/privacy-policy-uri :target "_blank" :class (stl/css :auth-link)} + [:a {:href cf/privacy-policy-uri :target "_blank" :class (stl/css :terms-link)} (tr "auth.privacy-policy")])]))) ;; --- PAGE: register validation @@ -321,9 +334,9 @@ [:& fm/form {:on-submit on-submit :form form - :class (stl/css :register-validate-form)} + :class (stl/css :auth-form)} - [:div {:class (stl/css :fields-row)} + [:div {:class (stl/css :form-row)} [:& fm/input {:name :fullname :label (tr "auth.fullname") :type "text" @@ -331,30 +344,28 @@ :class (stl/css :form-field)}]] (when (contains? cf/flags :terms-and-privacy-checkbox) - [:> terms-and-privacy*]) + [:> terms-and-privacy-checkbox*]) - [:> newsletter-options*] - - [:> fm/submit-button* - {:label (tr "auth.register-submit") - :disabled @submitted? - :class (stl/css :register-btn)}]])) + [:> newsletter-checkbox*] + [:> fm/submit-button* {:label (tr "auth.register-submit") + :disabled @submitted? + :class (stl/css :form-submit-btn)}]])) (mf/defc register-validate-page* [{:keys [params]}] - [:div {:class (stl/css :auth-form-wrapper :register-form)} + [:div {:class (stl/css :wrapper :register-form)} - [:div {:class (stl/css :auth-title-wrapper)} - [:h2 {:class (stl/css :auth-title) + [:div {:class (stl/css :title-wrapper)} + [:h2 {:class (stl/css :title) :data-testid "register-title"} (tr "auth.register-account-title")] - [:div {:class (stl/css :auth-subtitle)} (tr "auth.register-account-tagline")]] + [:div {:class (stl/css :subtitle)} (tr "auth.register-account-tagline")]] [:> register-validate-form* {:params params}] [:div {:class (stl/css :links)} - [:div {:class (stl/css :go-back)} + [:div {:class (stl/css :go-back-row)} [:> lk/link* {:action #(st/emit! (rt/nav :auth-register {})) :class (stl/css :go-back-link)} (tr "labels.go-back")]]]]) diff --git a/frontend/src/app/main/ui/auth/register.scss b/frontend/src/app/main/ui/auth/register.scss index 1b238cb353..a2cccfea22 100644 --- a/frontend/src/app/main/ui/auth/register.scss +++ b/frontend/src/app/main/ui/auth/register.scss @@ -2,59 +2,66 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL +@use "./common"; @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; @use "ds/typography.scss" as *; -@use "./common"; -.checkbox-terms-and-privacy, .checkbox-newsletter-updates { align-items: flex-start; } +.checkbox-terms-and-privacy { + align-items: flex-start; +} + .register-form { gap: var(--sp-xxl); } +.account-row { + display: flex; + justify-content: center; + gap: var(--sp-s); + padding: 0; +} + +.account-text { + @include use-typography("title-small"); + + text-align: right; + color: var(--color-foreground-secondary); +} + +.account-link { + @include use-typography("title-small"); + + text-align: left; + background-color: transparent; + border: none; + display: inline; + color: var(--color-accent-primary); + + &:hover { + text-decoration: underline; + } +} + .register-success { gap: var(--sp-xxl); } -.register-success .auth-title { +.register-success-title { @include use-typography("title-medium"); line-height: 1.2; + color: var(--color-foreground-primary); } -.notification-text { - @include use-typography("body-medium"); - - color: var(--color-foreground-secondary); -} - -.notification-text-email { - @include use-typography("title-medium"); - - line-height: 1.2; - color: var(--color-accent-primary); - margin-inline: $sz-36; -} - -.logo-btn { - block-size: $sz-40; -} - -.logo-container { - display: flex; - justify-content: flex-start; - inline-size: $sz-120; - margin-block-end: var(--sp-xxl); -} - -.terms-register { +.terms { @include use-typography("body-small"); display: flex; @@ -63,15 +70,15 @@ inline-size: 100%; } -.and-text { - border-block-end: $b-1 solid transparent; - color: var(--color-foreground-secondary); -} - -.auth-link { +.terms-link { color: var(--color-accent-primary); &:hover { text-decoration: underline; } } + +.terms-and { + border-block-end: $b-1 solid transparent; + color: var(--color-foreground-secondary); +} diff --git a/frontend/src/app/main/ui/auth/verify_token.cljs b/frontend/src/app/main/ui/auth/verify_token.cljs index 52a17761c6..ff08a3679f 100644 --- a/frontend/src/app/main/ui/auth/verify_token.cljs +++ b/frontend/src/app/main/ui/auth/verify_token.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.auth.verify-token (:require diff --git a/frontend/src/app/main/ui/comments.cljs b/frontend/src/app/main/ui/comments.cljs index 4a7032b62c..2d6b061ea0 100644 --- a/frontend/src/app/main/ui/comments.cljs +++ b/frontend/src/app/main/ui/comments.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.comments (:require-macros [app.main.style :as stl]) @@ -1063,6 +1063,14 @@ (fn [content] (st/emit! (dcm/add-comment thread content)))) + on-key-down + (mf/use-fn + (fn [event] + (when (kbd/esc? event) + (dom/prevent-default event) + (dom/stop-propagation event) + (st/emit! (dcm/close-thread))))) + on-cancel (mf/use-fn #(st/emit! (dcm/close-thread)))] @@ -1086,6 +1094,7 @@ :style {:left (str pos-x "px") :top (str pos-y "px") "--comment-height" (str max-height "px")} + :on-key-down on-key-down :on-click dom/stop-propagation} [:div {:class (stl/css :floating-thread-header)} diff --git a/frontend/src/app/main/ui/comments.scss b/frontend/src/app/main/ui/comments.scss index a54ad7d48e..547d6e95ef 100644 --- a/frontend/src/app/main/ui/comments.scss +++ b/frontend/src/app/main/ui/comments.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/components/button_link.cljs b/frontend/src/app/main/ui/components/button_link.cljs index 67929b9ce6..b6931dacd7 100644 --- a/frontend/src/app/main/ui/components/button_link.cljs +++ b/frontend/src/app/main/ui/components/button_link.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.button-link (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/button_link.scss b/frontend/src/app/main/ui/components/button_link.scss index 895ed0bc97..d080ee2c69 100644 --- a/frontend/src/app/main/ui/components/button_link.scss +++ b/frontend/src/app/main/ui/components/button_link.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/components/code_block.cljs b/frontend/src/app/main/ui/components/code_block.cljs index 0639e28ef0..a6e170ee51 100644 --- a/frontend/src/app/main/ui/components/code_block.cljs +++ b/frontend/src/app/main/ui/components/code_block.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.code-block (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/code_block.scss b/frontend/src/app/main/ui/components/code_block.scss index 101a889970..b71e8d75d9 100644 --- a/frontend/src/app/main/ui/components/code_block.scss +++ b/frontend/src/app/main/ui/components/code_block.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/components/color_bullet.cljs b/frontend/src/app/main/ui/components/color_bullet.cljs index 0230a3de21..111d96cb90 100644 --- a/frontend/src/app/main/ui/components/color_bullet.cljs +++ b/frontend/src/app/main/ui/components/color_bullet.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.color-bullet (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/color_bullet.scss b/frontend/src/app/main/ui/components/color_bullet.scss index 8ddd352a1a..da1c5b4059 100644 --- a/frontend/src/app/main/ui/components/color_bullet.scss +++ b/frontend/src/app/main/ui/components/color_bullet.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/components/color_input.cljs b/frontend/src/app/main/ui/components/color_input.cljs index e8a1075392..d9b80fa8e2 100644 --- a/frontend/src/app/main/ui/components/color_input.cljs +++ b/frontend/src/app/main/ui/components/color_input.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.color-input (:require diff --git a/frontend/src/app/main/ui/components/context_menu_a11y.cljs b/frontend/src/app/main/ui/components/context_menu_a11y.cljs index e2e824c19e..925faf4972 100644 --- a/frontend/src/app/main/ui/components/context_menu_a11y.cljs +++ b/frontend/src/app/main/ui/components/context_menu_a11y.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.context-menu-a11y (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/context_menu_a11y.scss b/frontend/src/app/main/ui/components/context_menu_a11y.scss index 4d117b3ea0..c1e211b549 100644 --- a/frontend/src/app/main/ui/components/context_menu_a11y.scss +++ b/frontend/src/app/main/ui/components/context_menu_a11y.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/z-index.scss" as *; diff --git a/frontend/src/app/main/ui/components/copy_button.cljs b/frontend/src/app/main/ui/components/copy_button.cljs index d7e69f9066..b3d5c0f1f6 100644 --- a/frontend/src/app/main/ui/components/copy_button.cljs +++ b/frontend/src/app/main/ui/components/copy_button.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.copy-button (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/copy_button.scss b/frontend/src/app/main/ui/components/copy_button.scss index 32a3d131f7..3173698c92 100644 --- a/frontend/src/app/main/ui/components/copy_button.scss +++ b/frontend/src/app/main/ui/components/copy_button.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/components/dropdown.cljs b/frontend/src/app/main/ui/components/dropdown.cljs index 4a0a1b0590..316aadc842 100644 --- a/frontend/src/app/main/ui/components/dropdown.cljs +++ b/frontend/src/app/main/ui/components/dropdown.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.dropdown (:require diff --git a/frontend/src/app/main/ui/components/dropdown_menu.cljs b/frontend/src/app/main/ui/components/dropdown_menu.cljs index 681682fdae..a5936bae07 100644 --- a/frontend/src/app/main/ui/components/dropdown_menu.cljs +++ b/frontend/src/app/main/ui/components/dropdown_menu.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.dropdown-menu (:require diff --git a/frontend/src/app/main/ui/components/editable_label.cljs b/frontend/src/app/main/ui/components/editable_label.cljs index 020a9276b0..4abae09d28 100644 --- a/frontend/src/app/main/ui/components/editable_label.cljs +++ b/frontend/src/app/main/ui/components/editable_label.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.editable-label (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/editable_label.scss b/frontend/src/app/main/ui/components/editable_label.scss index 7033160fdd..8c998ee8ca 100644 --- a/frontend/src/app/main/ui/components/editable_label.scss +++ b/frontend/src/app/main/ui/components/editable_label.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/components/editable_select.cljs b/frontend/src/app/main/ui/components/editable_select.cljs index a3c43866fe..470f95025c 100644 --- a/frontend/src/app/main/ui/components/editable_select.cljs +++ b/frontend/src/app/main/ui/components/editable_select.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.editable-select (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/editable_select.scss b/frontend/src/app/main/ui/components/editable_select.scss index 76fda98365..490e549c0a 100644 --- a/frontend/src/app/main/ui/components/editable_select.scss +++ b/frontend/src/app/main/ui/components/editable_select.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL // FIXME: we need this import for %asset-element @use "refactor/basic-rules.scss" as deprecated; @@ -39,7 +39,7 @@ .custom-select-dropdown { @extend %dropdown-wrapper; - width: fit-content; + width: max-content; max-height: px2rem(320); // TODO: when this gets addressed in the DS, use a token .separator { margin: 0; diff --git a/frontend/src/app/main/ui/components/file_uploader.cljs b/frontend/src/app/main/ui/components/file_uploader.cljs index e4723cf334..e7d8769bca 100644 --- a/frontend/src/app/main/ui/components/file_uploader.cljs +++ b/frontend/src/app/main/ui/components/file_uploader.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.file-uploader (:require diff --git a/frontend/src/app/main/ui/components/forms.cljs b/frontend/src/app/main/ui/components/forms.cljs index 88585a753e..e11f4a3cd0 100644 --- a/frontend/src/app/main/ui/components/forms.cljs +++ b/frontend/src/app/main/ui/components/forms.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.forms (:require-macros [app.main.style :as stl]) @@ -180,11 +180,17 @@ (cond (and touched? (:message error) show-error) - (let [message (:message error)] + (let [message (:message error) + options (:options error)] [:div {:id (dm/str "error-" input-name) :class (stl/css :error) :data-testid (dm/str data-testid "-error")} - message]) + message + (when (seq options) + [:ul {:class (stl/css :error-options)} + (for [opt options] + [:li {:key opt + :class (stl/css :error-option)} opt])])]) ;; FIXME: DEPRECATED (and touched? (:code error) show-error) diff --git a/frontend/src/app/main/ui/components/forms.scss b/frontend/src/app/main/ui/components/forms.scss index 30901f43ec..445fce340b 100644 --- a/frontend/src/app/main/ui/components/forms.scss +++ b/frontend/src/app/main/ui/components/forms.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; @@ -168,6 +168,16 @@ font-size: deprecated.$fs-14; } +.error-options { + margin-block: var(--sp-xxs); + padding-inline-start: var(--sp-l); + list-style-type: disc; +} + +.error-option { + margin-block: var(--sp-xxs); +} + .hint { @include t.use-typography("body-small"); diff --git a/frontend/src/app/main/ui/components/link.cljs b/frontend/src/app/main/ui/components/link.cljs index cb41b90031..feeb5a5c98 100644 --- a/frontend/src/app/main/ui/components/link.cljs +++ b/frontend/src/app/main/ui/components/link.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.link (:require diff --git a/frontend/src/app/main/ui/components/link_button.cljs b/frontend/src/app/main/ui/components/link_button.cljs index b9add8c91f..9e4ba9f768 100644 --- a/frontend/src/app/main/ui/components/link_button.cljs +++ b/frontend/src/app/main/ui/components/link_button.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.link-button (:require diff --git a/frontend/src/app/main/ui/components/numeric_input.cljs b/frontend/src/app/main/ui/components/numeric_input.cljs index 9225674adf..b933c8f1ae 100644 --- a/frontend/src/app/main/ui/components/numeric_input.cljs +++ b/frontend/src/app/main/ui/components/numeric_input.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.numeric-input (:require diff --git a/frontend/src/app/main/ui/components/organization_avatar.cljs b/frontend/src/app/main/ui/components/organization_avatar.cljs index 8b883ca56d..d6c635f005 100644 --- a/frontend/src/app/main/ui/components/organization_avatar.cljs +++ b/frontend/src/app/main/ui/components/organization_avatar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.organization-avatar (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/organization_avatar.scss b/frontend/src/app/main/ui/components/organization_avatar.scss index 4bc1d991d1..5009c20f86 100644 --- a/frontend/src/app/main/ui/components/organization_avatar.scss +++ b/frontend/src/app/main/ui/components/organization_avatar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/colors.scss" as *; diff --git a/frontend/src/app/main/ui/components/portal.cljs b/frontend/src/app/main/ui/components/portal.cljs index b767297a41..a8c1e4fe1d 100644 --- a/frontend/src/app/main/ui/components/portal.cljs +++ b/frontend/src/app/main/ui/components/portal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.portal (:require diff --git a/frontend/src/app/main/ui/components/progress.cljs b/frontend/src/app/main/ui/components/progress.cljs index 00c5e3028d..52fb0b2393 100644 --- a/frontend/src/app/main/ui/components/progress.cljs +++ b/frontend/src/app/main/ui/components/progress.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.progress "Assets exportation common components." diff --git a/frontend/src/app/main/ui/components/progress.scss b/frontend/src/app/main/ui/components/progress.scss index c49c11b6a8..333ec8be1c 100644 --- a/frontend/src/app/main/ui/components/progress.scss +++ b/frontend/src/app/main/ui/components/progress.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/components/radio_buttons.cljs b/frontend/src/app/main/ui/components/radio_buttons.cljs index 923dd0267d..73c778db83 100644 --- a/frontend/src/app/main/ui/components/radio_buttons.cljs +++ b/frontend/src/app/main/ui/components/radio_buttons.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.radio-buttons (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/radio_buttons.scss b/frontend/src/app/main/ui/components/radio_buttons.scss index 40abfe173b..431b5db75a 100644 --- a/frontend/src/app/main/ui/components/radio_buttons.scss +++ b/frontend/src/app/main/ui/components/radio_buttons.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/components/reorder_handler.cljs b/frontend/src/app/main/ui/components/reorder_handler.cljs index 92273ede8d..2d1ddd36a2 100644 --- a/frontend/src/app/main/ui/components/reorder_handler.cljs +++ b/frontend/src/app/main/ui/components/reorder_handler.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.reorder-handler (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/reorder_handler.scss b/frontend/src/app/main/ui/components/reorder_handler.scss index a4249bd0dc..bd12353604 100644 --- a/frontend/src/app/main/ui/components/reorder_handler.scss +++ b/frontend/src/app/main/ui/components/reorder_handler.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .reorder { cursor: grab; diff --git a/frontend/src/app/main/ui/components/search_bar.cljs b/frontend/src/app/main/ui/components/search_bar.cljs index 8fbf4faf96..6727587004 100644 --- a/frontend/src/app/main/ui/components/search_bar.cljs +++ b/frontend/src/app/main/ui/components/search_bar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.search-bar (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/search_bar.scss b/frontend/src/app/main/ui/components/search_bar.scss index 59f37812b2..49019e46ae 100644 --- a/frontend/src/app/main/ui/components/search_bar.scss +++ b/frontend/src/app/main/ui/components/search_bar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/components/select.cljs b/frontend/src/app/main/ui/components/select.cljs index 858dfb3ecf..e1373717f0 100644 --- a/frontend/src/app/main/ui/components/select.cljs +++ b/frontend/src/app/main/ui/components/select.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.select (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/select.scss b/frontend/src/app/main/ui/components/select.scss index 438e0873fa..3774b11719 100644 --- a/frontend/src/app/main/ui/components/select.scss +++ b/frontend/src/app/main/ui/components/select.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/components/title_bar.cljs b/frontend/src/app/main/ui/components/title_bar.cljs index d294504dd4..ee5cbea2ea 100644 --- a/frontend/src/app/main/ui/components/title_bar.cljs +++ b/frontend/src/app/main/ui/components/title_bar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.title-bar (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/title_bar.scss b/frontend/src/app/main/ui/components/title_bar.scss index 3f8045feeb..198ca4b657 100644 --- a/frontend/src/app/main/ui/components/title_bar.scss +++ b/frontend/src/app/main/ui/components/title_bar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/confirm.cljs b/frontend/src/app/main/ui/confirm.cljs index 66621e0d07..04b7b797e4 100644 --- a/frontend/src/app/main/ui/confirm.cljs +++ b/frontend/src/app/main/ui/confirm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.confirm (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/confirm.scss b/frontend/src/app/main/ui/confirm.scss index afcffa7a48..2f61a5bb16 100644 --- a/frontend/src/app/main/ui/confirm.scss +++ b/frontend/src/app/main/ui/confirm.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/context.cljs b/frontend/src/app/main/ui/context.cljs index 0ba58aedb6..3e1312c417 100644 --- a/frontend/src/app/main/ui/context.cljs +++ b/frontend/src/app/main/ui/context.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.context (:require diff --git a/frontend/src/app/main/ui/css_cursors.cljs b/frontend/src/app/main/ui/css_cursors.cljs index 9c0a97276e..8541d7bedf 100644 --- a/frontend/src/app/main/ui/css_cursors.cljs +++ b/frontend/src/app/main/ui/css_cursors.cljs @@ -40,9 +40,21 @@ (init-static-cursor-style style "create-polygon" cur/create-polygon) (init-static-cursor-style style "create-rectangle" cur/create-rectangle) (init-static-cursor-style style "create-shape" cur/create-shape) + (init-static-cursor-style style "draw" cur/draw) + (init-static-cursor-style style "draw-add" cur/draw-add) + (init-static-cursor-style style "draw-node" cur/draw-node) + (init-static-cursor-style style "draw-remove" cur/draw-remove) (init-static-cursor-style style "duplicate" cur/duplicate) (init-static-cursor-style style "hand" cur/hand) + (init-static-cursor-style style "move" cur/move) + (init-static-cursor-style style "move-add" cur/move-add) + (init-static-cursor-style style "move-copy" cur/move-copy) + (init-static-cursor-style style "move-curve" cur/move-curve) + (init-static-cursor-style style "move-handles" cur/move-handles) + (init-static-cursor-style style "move-move" cur/move-move) + (init-static-cursor-style style "move-node" cur/move-node) (init-static-cursor-style style "move-pointer" cur/move-pointer) + (init-static-cursor-style style "move-remove" cur/move-remove) (init-static-cursor-style style "pen" cur/pen) (init-static-cursor-style style "pen-node" cur/pen-node) (init-static-cursor-style style "pencil" cur/pencil) diff --git a/frontend/src/app/main/ui/cursors.clj b/frontend/src/app/main/ui/cursors.clj index b55b560766..eef490b7f9 100644 --- a/frontend/src/app/main/ui/cursors.clj +++ b/frontend/src/app/main/ui/cursors.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.cursors (:require @@ -17,6 +17,7 @@ (def default-hotspot-y 12) (def default-rotation 0) (def default-height 20) +(def default-width 20) (defn parse-svg [svg-data] (-> svg-data @@ -51,7 +52,7 @@ (str/replace #"\s+$" ""))) (defn encode-svg-cursor - [id rotation x y height] + [id rotation x y height width] (let [svg-path (str cursor-folder "/" (name id) ".svg") data (-> svg-path io/resource slurp parse-svg) data (u/percent-encode data) @@ -59,15 +60,16 @@ data (if rotation (str/fmt "%3Cg transform='rotate(%s 8,8)'%3E%s%3C/g%3E" rotation data) data)] - (str "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='20px' " + (str "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='" width "px' " "height='" height "px' %3E" data "%3C/svg%3E\") " x " " y ", auto"))) (defmacro cursor-ref - "Creates a static cursor given its name, rotation and x/y hotspot" - ([id] (encode-svg-cursor id default-rotation default-hotspot-x default-hotspot-y default-height)) - ([id rotation] (encode-svg-cursor id rotation default-hotspot-x default-hotspot-y default-height)) - ([id rotation x y] (encode-svg-cursor id rotation x y default-height)) - ([id rotation x y height] (encode-svg-cursor id rotation x y height))) + "Creates a static SVG cursor." + ([id] (encode-svg-cursor id default-rotation default-hotspot-x default-hotspot-y default-height default-width)) + ([id rotation] (encode-svg-cursor id rotation default-hotspot-x default-hotspot-y default-height default-width)) + ([id rotation x y] (encode-svg-cursor id rotation x y default-height default-width)) + ([id rotation x y height] (encode-svg-cursor id rotation x y height default-width)) + ([id rotation x y height width] (encode-svg-cursor id rotation x y height width))) (defmacro cursor-fn "Creates a dynamic cursor that can be rotated in runtime" @@ -75,7 +77,8 @@ (let [[cp1 cp2] (-> (encode-svg-cursor id "$$$" default-hotspot-x default-hotspot-y - default-height) + default-height + default-width) (str/split #"\$\$\$"))] `(fn [rot#] (str/concat ~cp1 (+ ~initial rot#) ~cp2)))) diff --git a/frontend/src/app/main/ui/cursors.cljs b/frontend/src/app/main/ui/cursors.cljs index e618b6014a..9453f285d5 100644 --- a/frontend/src/app/main/ui/cursors.cljs +++ b/frontend/src/app/main/ui/cursors.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.cursors (:require-macros [app.main.ui.cursors :refer [cursor-ref cursor-fn collect-cursors]])) @@ -14,9 +14,21 @@ (def ^:cursor create-polygon (cursor-ref :create-polygon)) (def ^:cursor create-rectangle (cursor-ref :create-rectangle)) (def ^:cursor create-shape (cursor-ref :create-shape)) +(def ^:cursor draw (cursor-ref :draw 0 0 0)) +(def ^:cursor draw-add (cursor-ref :draw-add 0 0 0 25 25)) +(def ^:cursor draw-node (cursor-ref :draw-node 0 0 0 25 25)) +(def ^:cursor draw-remove (cursor-ref :draw-remove 0 0 0 25 25)) (def ^:cursor duplicate (cursor-ref :duplicate 0 0 0)) (def ^:cursor hand (cursor-ref :hand)) +(def ^:cursor move (cursor-ref :move 0 0 0 25 25)) +(def ^:cursor move-add (cursor-ref :move-add 0 0 0 25 25)) +(def ^:cursor move-copy (cursor-ref :move-copy 0 0 0 25 25)) +(def ^:cursor move-curve (cursor-ref :move-curve 0 0 0 25 25)) +(def ^:cursor move-handles (cursor-ref :move-handles 0 0 0 25 25)) +(def ^:cursor move-move (cursor-ref :move-move 0 0 0 25 25)) +(def ^:cursor move-node (cursor-ref :move-node 0 0 0 25 25)) (def ^:cursor move-pointer (cursor-ref :move-pointer)) +(def ^:cursor move-remove (cursor-ref :move-remove 0 0 0 25 25)) (def ^:cursor pen (cursor-ref :pen 0 0 0)) (def ^:cursor pen-node (cursor-ref :pen-node 0 0 10 36)) (def ^:cursor pencil (cursor-ref :pencil 0 0 24)) diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index 20daf964a7..c83a33e97d 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard (:require-macros [app.main.style :as stl]) @@ -27,6 +27,7 @@ [app.main.ui.dashboard.files :refer [files-section*]] [app.main.ui.dashboard.fonts :refer [fonts-page* font-providers-page*]] [app.main.ui.dashboard.import] + [app.main.ui.dashboard.layout-toggle :as lt] [app.main.ui.dashboard.libraries :refer [libraries-page*]] [app.main.ui.dashboard.projects :refer [projects-section*]] [app.main.ui.dashboard.search :refer [search-page*]] @@ -51,7 +52,7 @@ (mf/defc dashboard-content* {::mf/private true} - [{:keys [team projects project section search-term profile default-project]}] + [{:keys [team projects project section search-term profile default-project layout on-layout-change]}] (let [container (mf/use-ref) content-width (mf/use-state 0) @@ -100,18 +101,18 @@ :dashboard-recent (when (seq projects) [:* - [:> projects-section* - {:team team - :projects projects - :profile profile}] + [:> projects-section* {:team team + :projects projects + :profile profile + :layout layout + :on-layout-change on-layout-change}] (when ^boolean show-templates? - [:> templates-section* - {:profile profile - :project-id project-id - :team-id team-id - :default-project-id default-project-id - :content-width @content-width}])]) + [:> templates-section* {:profile profile + :project-id project-id + :team-id team-id + :default-project-id default-project-id + :content-width @content-width}])]) :dashboard-fonts [:> fonts-page* {:team team}] @@ -123,14 +124,15 @@ (when project [:* [:> files-section* {:team team - :project project}] + :project project + :layout layout + :on-layout-change on-layout-change}] (when ^boolean show-templates? - [:> templates-section* - {:profile profile - :team-id team-id - :project-id project-id - :default-project-id default-project-id - :content-width @content-width}])]) + [:> templates-section* {:profile profile + :team-id team-id + :project-id project-id + :default-project-id default-project-id + :content-width @content-width}])]) :dashboard-search [:> search-page* {:team team @@ -155,7 +157,9 @@ :dashboard-deleted [:> deleted-section* {:team team :projects projects - :profile profile}] + :profile profile + :layout layout + :on-layout-change on-layout-change}] nil)])) @@ -211,7 +215,7 @@ (mf/with-layout-effect [plugin-url team-id project-id] - (when plugin-url + (when (and plugin-url project-id) (->> (dp/fetch-manifest plugin-url) (rx/subs! (fn [plugin] @@ -313,7 +317,15 @@ (mf/with-memo [projects] (->> projects (filter :is-default) - (first)))] + (first))) + + layout* (hooks/use-persisted-state lt/layout-key lt/default-layout) + layout (deref layout*) + + on-layout-change + (mf/use-fn + (fn [value] + (reset! layout* (keyword value))))] (hooks/use-shortcuts ::dashboard sc/shortcuts-dashboard :dashboard) @@ -347,22 +359,22 @@ ;; team is already set so don't put the team into mf/deps. [:main {:class (stl/css :dashboard) :key (dm/str (:id team))} - [:> sidebar* - {:team team - :projects projects - :project project - :default-project default-project - :profile profile - :section section - :search-term search-term}] - [:> dashboard-content* - {:projects projects - :profile profile - :project project - :default-project default-project - :section section - :search-term search-term - :team team}]]])) + [:> sidebar* {:team team + :projects projects + :project project + :default-project default-project + :profile profile + :section section + :search-term search-term}] + [:> dashboard-content* {:projects projects + :profile profile + :project project + :default-project default-project + :section section + :search-term search-term + :team team + :layout layout + :on-layout-change on-layout-change}]]])) (mf/defc dashboard-page* {::mf/lazy-load true} diff --git a/frontend/src/app/main/ui/dashboard.scss b/frontend/src/app/main/ui/dashboard.scss index c2ca60b679..7db58cd983 100644 --- a/frontend/src/app/main/ui/dashboard.scss +++ b/frontend/src/app/main/ui/dashboard.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/dashboard/change_owner.cljs b/frontend/src/app/main/ui/dashboard/change_owner.cljs index 05d9990b69..b6eb800030 100644 --- a/frontend/src/app/main/ui/dashboard/change_owner.cljs +++ b/frontend/src/app/main/ui/dashboard/change_owner.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.change-owner (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/change_owner.scss b/frontend/src/app/main/ui/dashboard/change_owner.scss index 01c8d875bd..df51c6e86a 100644 --- a/frontend/src/app/main/ui/dashboard/change_owner.scss +++ b/frontend/src/app/main/ui/dashboard/change_owner.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/dashboard/check_updates.cljs b/frontend/src/app/main/ui/dashboard/check_updates.cljs new file mode 100644 index 0000000000..407b6b434d --- /dev/null +++ b/frontend/src/app/main/ui/dashboard/check_updates.cljs @@ -0,0 +1,307 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.main.ui.dashboard.check-updates + (:require-macros [app.main.style :as stl]) + (:require + [app.common.version :as v] + [app.config :as cf] + [app.main.data.event :as ev] + [app.main.data.modal :as modal] + [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.util.dom :as dom] + [app.util.http :as http] + [app.util.i18n :refer [tr]] + [beicon.v2.core :as rx] + [clojure.string :as cstr] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(def ^:private telemetry-origin + "check-updates-modal") + +(def ^:private highlights-md-url + "https://raw.githubusercontent.com/penpot/penpot/refs/heads/staging/HIGHLIGHTS.md") + +(def ^:private changelog-url + "https://github.com/penpot/penpot/blob/staging/CHANGES.md") + +(def ^:private release-notes-url + "https://penpot.app/release-notes") + +(def ^:private version-heading-re + #"(?m)^##\s+(\d+\.\d+\.\d+)(.*)$") + +(def ^:private bullet-re + #"^- (.+)$") + +(defn- unreleased-suffix? + [suffix] + (str/includes? (str/lower (or suffix "")) "unreleased")) + +(defn- parse-section-items + [section] + (->> (cstr/split-lines section) + (keep (fn [line] + (when-let [[_ item] (re-matches bullet-re (str/trim line))] + item))) + vec)) + +(defn parse-highlights + "Parse HIGHLIGHTS.md into released version sections with bullet items. + Skips Unreleased headings. Preserves file order (newest first)." + [markdown] + (if-not (string? markdown) + [] + (->> (str/split markdown #"(?m)(?=^##\s+\d+\.\d+\.\d+)") + (keep (fn [part] + (when-let [[_ version suffix] (re-find version-heading-re part)] + (when-not (unreleased-suffix? suffix) + {:version version + :items (parse-section-items part)})))) + vec))) + +(defn parse-latest-released-version + "Return the first non-unreleased `## X.Y.Z` heading from a highlights body." + [markdown] + (some-> (parse-highlights markdown) first :version)) + +(defn highlights-until-installed + "Keep released sections newer than the installed version (major, minor, + patch). Stops at the installed version or any older section." + [highlights installed] + (into [] + (take-while #(v/newer? (:version %) installed)) + highlights)) + +(defn- show-available-dialog + [{:keys [installed latest highlights]}] + (st/emit! (modal/show {:type :check-updates-available + :installed installed + :latest latest + :highlights highlights}))) + +(defn- show-uptodate-dialog + [version] + (st/emit! (modal/show {:type :check-updates-uptodate + :version version}))) + +(defn- show-unable-dialog + [] + (st/emit! (modal/show {:type :check-updates-unable}))) + +(defn- handle-highlights + [installed body] + (let [sections (parse-highlights body) + latest (some-> sections first :version)] + (cond + (nil? latest) + (show-unable-dialog) + + (not (v/newer? latest installed)) + (show-uptodate-dialog installed) + + :else + (show-available-dialog + {:installed installed + :latest latest + :highlights (highlights-until-installed sections installed)})))) + +(defn check-for-updates! + ([current-version] + (check-for-updates! current-version nil)) + ([current-version {:keys [on-start on-finish]}] + (when on-start (on-start)) + (->> (http/send! {:method :get + :mode :cors + :omit-default-headers true + :uri highlights-md-url + :response-type :text}) + (rx/subs! + (fn [response] + (when on-finish (on-finish)) + (if (http/success? response) + (handle-highlights current-version (:body response)) + (show-unable-dialog))) + (fn [_cause] + (when on-finish (on-finish)) + (show-unable-dialog)))))) + +(mf/defc check-updates-unable-modal* + {::mf/register modal/components + ::mf/register-as :check-updates-unable} + [_] + (let [on-close + (mf/use-fn #(st/emit! (modal/hide))) + + on-try-again + (mf/use-fn + (fn [] + (st/emit! (modal/hide)) + (check-for-updates! (:base cf/version))))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-container)} + [:div {:class (stl/css :modal-header)} + [:div {:class (stl/css :modal-title-row)} + [:> icon* {:icon-id i/msg-neutral + :class (stl/css :modal-title-icon) + :aria-hidden true}] + [:> heading* {:level 2 + :typography "headline-medium" + :class (stl/css :modal-title)} + (tr "dashboard.check-updates.unable-title")]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-close + :icon i/close + :class (stl/css :modal-close-btn)}]] + + [:div {:class (stl/css :modal-content)} + [:> text* {:as "p" + :typography t/body-large + :class (stl/css :modal-msg)} + (tr "dashboard.check-updates.unable-message")] + [:> text* {:as "p" + :typography t/body-large + :class (stl/css :modal-msg)} + (tr "dashboard.check-updates.unable-hint")]] + + [:div {:class (stl/css :modal-footer)} + [:> button* {:variant "primary" + :on-click on-try-again} + (tr "dashboard.check-updates.try-again")]]]])) + +(mf/defc check-updates-uptodate-modal* + {::mf/register modal/components + ::mf/register-as :check-updates-uptodate} + [{:keys [version]}] + (let [on-close + (mf/use-fn #(st/emit! (modal/hide)))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-container)} + [:div {:class (stl/css :modal-header)} + [:div {:class (stl/css :modal-title-row)} + [:> icon* {:icon-id i/tick + :class (stl/css :modal-title-icon) + :aria-hidden true}] + [:> heading* {:level 2 + :typography "headline-medium" + :class (stl/css :modal-title)} + (tr "dashboard.check-updates.uptodate-title")]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-close + :icon i/close + :class (stl/css :modal-close-btn)}]] + + [:div {:class (stl/css :modal-content)} + [:> text* {:as "p" + :typography t/body-large + :class (stl/css :modal-msg)} + (tr "dashboard.check-updates.uptodate-message") + " " + [:span {:class (stl/css :version)} version]]] + + [:div {:class (stl/css :modal-footer)} + [:> button* {:variant "secondary" + :on-click on-close} + (tr "labels.close")]]]])) + +(mf/defc check-updates-available-modal* + {::mf/register modal/components + ::mf/register-as :check-updates-available} + [{:keys [installed latest highlights]}] + (let [on-close + (mf/use-fn #(st/emit! (modal/hide))) + + on-changelog + (mf/use-fn + (mf/deps installed) + (fn [] + (st/emit! (ev/event {::ev/name "explore-changelog-click" + ::ev/origin telemetry-origin + :version installed})) + (dom/open-new-window changelog-url))) + + on-release-notes + (mf/use-fn + (mf/deps installed) + (fn [] + (st/emit! (ev/event {::ev/name "explore-product-updates-click" + ::ev/origin telemetry-origin + :version installed})) + (dom/open-new-window release-notes-url)))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-container :modal-container-available)} + [:div {:class (stl/css :modal-header)} + [:div {:class (stl/css :modal-title-row)} + [:> icon* {:icon-id i/info + :class (stl/css :modal-title-icon) + :aria-hidden true}] + [:> heading* {:level 2 + :typography "headline-medium" + :class (stl/css :modal-title)} + (tr "dashboard.check-updates.available-title")]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-close + :icon i/close + :class (stl/css :modal-close-btn)}]] + + [:div {:class (stl/css :version-bar)} + [:span {:class (stl/css :version-bar-side)} + (tr "dashboard.check-updates.installed-version") + " " + [:span {:class (stl/css :version)} installed]] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :version-bar-arrow) + :size "s" + :aria-hidden true}] + [:span {:class (stl/css :version-bar-side)} + (tr "dashboard.check-updates.latest-version") + " " + [:span {:class (stl/css :version :version-accent)} latest]]] + + [:div {:class (stl/css :modal-content)} + [:> text* {:as "p" + :typography t/body-large + :class (stl/css :modal-msg)} + (tr "dashboard.check-updates.available-message")] + + [:> text* {:as "h3" + :typography t/headline-small + :class (stl/css :highlights-title)} + (tr "dashboard.check-updates.highlights-title")] + + [:div {:class (stl/css :highlights-scroll)} + (for [section highlights] + (let [version (:version section) + items (:items section)] + [:div {:key version + :class (stl/css :highlights-section)} + [:div {:class (stl/css :highlights-version)} version] + [:ul {:class (stl/css :highlights-list)} + (for [item items] + [:li {:key item + :class (stl/css :highlights-item)} + item])]]))]] + + [:div {:class (stl/css :modal-footer :modal-footer-available)} + [:> button* {:variant "secondary" + :on-click on-changelog} + (tr "dashboard.check-updates.view-changelog")] + [:> button* {:variant "primary" + :on-click on-release-notes} + (tr "dashboard.check-updates.view-release-notes")]]]])) diff --git a/frontend/src/app/main/ui/dashboard/check_updates.scss b/frontend/src/app/main/ui/dashboard/check_updates.scss new file mode 100644 index 0000000000..26f5855a09 --- /dev/null +++ b/frontend/src/app/main/ui/dashboard/check_updates.scss @@ -0,0 +1,175 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS SUBSIDIARY SL + +@use "ds/_utils.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/typography.scss" as *; + +.modal-overlay { + display: flex; + justify-content: center; + align-items: center; + position: fixed; + inset: 0; + z-index: var(--z-index-set); + background-color: var(--color-overlay-default); +} + +.modal-container { + position: relative; + display: flex; + flex-direction: column; + gap: var(--sp-xxl); + padding: var(--sp-xxxl); + border-radius: $br-8; + background-color: var(--color-background-primary); + border: $b-2 solid var(--color-background-quaternary); + min-inline-size: $sz-364; + max-inline-size: $sz-512; +} + +.modal-container-available { + max-inline-size: $sz-480; + max-block-size: min(90vh, $sz-712); +} + +.modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--sp-m); + padding-inline-end: var(--sp-xl); +} + +.modal-title-row { + display: flex; + align-items: center; + gap: var(--sp-s); +} + +.modal-title-icon { + flex-shrink: 0; + color: var(--color-foreground-primary); +} + +.modal-title { + color: var(--color-foreground-primary); +} + +.modal-close-btn { + position: absolute; + inset-block-start: var(--sp-m); + inset-inline-end: var(--sp-m); +} + +.version-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-m); + padding: var(--sp-m) var(--sp-l); + border-radius: $br-8; + background-color: var(--color-background-tertiary); + color: var(--color-foreground-secondary); +} + +.version-bar-side { + @include use-typography("body-large"); +} + +.version-bar-arrow { + flex-shrink: 0; + color: var(--color-foreground-secondary); +} + +.modal-content { + display: flex; + flex-direction: column; + gap: var(--sp-s); + min-block-size: 0; +} + +.modal-msg { + margin: 0; + color: var(--color-foreground-secondary); +} + +.version { + color: var(--color-foreground-primary); + font-weight: 700; +} + +.version-accent { + color: var(--color-accent-primary); +} + +.highlights-title { + margin: var(--sp-m) 0 0; + color: var(--color-foreground-secondary); + text-transform: uppercase; +} + +.highlights-scroll { + display: flex; + flex-direction: column; + gap: var(--sp-l); + margin-block-start: var(--sp-s); + padding-inline-end: var(--sp-s); + max-block-size: $sz-284; + overflow-y: auto; +} + +.highlights-section { + display: flex; + flex-direction: column; + gap: var(--sp-s); +} + +.highlights-version { + @include use-typography("headline-small"); + + color: var(--color-foreground-primary); + font-weight: 700; +} + +.highlights-list { + display: flex; + flex-direction: column; + gap: var(--sp-xs); + margin: 0; + padding: 0; + list-style: none; +} + +.highlights-item { + @include use-typography("body-large"); + + position: relative; + padding-inline-start: var(--sp-l); + color: var(--color-foreground-secondary); + + &::before { + content: ""; + position: absolute; + inset-block-start: 0.55em; + inset-inline-start: 0; + inline-size: $sz-6; + block-size: $sz-6; + border-radius: $br-circle; + background-color: var(--color-accent-primary); + } +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: var(--sp-s); +} + +.modal-footer-available { + flex-shrink: 0; +} diff --git a/frontend/src/app/main/ui/dashboard/comments.cljs b/frontend/src/app/main/ui/dashboard/comments.cljs index 1090ba768d..7f0da6b00e 100644 --- a/frontend/src/app/main/ui/dashboard/comments.cljs +++ b/frontend/src/app/main/ui/dashboard/comments.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.comments (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/comments.scss b/frontend/src/app/main/ui/dashboard/comments.scss index e814e94fd8..4c2b2b86a5 100644 --- a/frontend/src/app/main/ui/dashboard/comments.scss +++ b/frontend/src/app/main/ui/dashboard/comments.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/dashboard/deleted.cljs b/frontend/src/app/main/ui/dashboard/deleted.cljs index 9a46b697ba..2f41c028e6 100644 --- a/frontend/src/app/main/ui/dashboard/deleted.cljs +++ b/frontend/src/app/main/ui/dashboard/deleted.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.deleted (:require-macros [app.main.style :as stl]) @@ -219,16 +219,8 @@ (tr "labels.deleted")]]])) (mf/defc deleted-section* - [{:keys [team projects]}] - (let [layout* (hooks/use-persisted-state lt/layout-key lt/default-layout) - layout (deref layout*) - - on-layout-change - (mf/use-fn - (fn [value] - (reset! layout* (keyword value)))) - - deleted-map + [{:keys [team projects layout on-layout-change]}] + (let [deleted-map (mf/deref ref:deleted-files) projects diff --git a/frontend/src/app/main/ui/dashboard/deleted.scss b/frontend/src/app/main/ui/dashboard/deleted.scss index bfa78241db..d6e9b376ea 100644 --- a/frontend/src/app/main/ui/dashboard/deleted.scss +++ b/frontend/src/app/main/ui/dashboard/deleted.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs index c3d466e3d3..f644730dba 100644 --- a/frontend/src/app/main/ui/dashboard/file_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.file-menu (:require @@ -37,7 +37,7 @@ (defn- get-team-name [team] (if (:is-default team) - (tr "dashboard.your-penpot") + (tr "dashboard.personal-projects") (:name team))) (defn- group-by-team diff --git a/frontend/src/app/main/ui/dashboard/files.cljs b/frontend/src/app/main/ui/dashboard/files.cljs index ed3781faba..436691f5a1 100644 --- a/frontend/src/app/main/ui/dashboard/files.cljs +++ b/frontend/src/app/main/ui/dashboard/files.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.files (:require-macros [app.main.style :as stl]) @@ -137,7 +137,7 @@ :on-import on-import}])]])) (mf/defc files-section* - [{:keys [project team]}] + [{:keys [project team layout on-layout-change]}] (let [files (mf/deref refs/files) project-id (get project :id) @@ -147,7 +147,6 @@ (sort-by :modified-at) (reverse))) - can-edit? (-> team :permissions :can-edit) project-id (:id project) is-draft-proyect (:is-default project) @@ -155,19 +154,16 @@ [rowref limit] (hooks/use-dynamic-grid-item-width) file-count (or (count files) 0) + + loading? (and (some? (:count project)) + (not= (:count project) file-count)) + empty-state-viewer (and (not can-edit?) - (= 0 file-count)) + (= 0 file-count) + (not loading?)) selected-files (mf/deref refs/selected-files) - layout* (hooks/use-persisted-state lt/layout-key lt/default-layout) - layout (deref layout*) - - on-layout-change - (mf/use-fn - (fn [value] - (reset! layout* (keyword value)))) - on-file-created (mf/use-fn (fn [file-data] @@ -216,7 +212,7 @@ (tr "dashboard.empty-placeholder-drafts-subtitle") (tr "dashboard.empty-placeholder-files-subtitle"))}] [:> grid* {:project project - :files files + :files (if loading? nil files) :selected-files selected-files :can-edit can-edit? :origin :files diff --git a/frontend/src/app/main/ui/dashboard/files.scss b/frontend/src/app/main/ui/dashboard/files.scss index 48676efd47..8f69e352e9 100644 --- a/frontend/src/app/main/ui/dashboard/files.scss +++ b/frontend/src/app/main/ui/dashboard/files.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; diff --git a/frontend/src/app/main/ui/dashboard/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs index 6c248beb04..703a162e25 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.cljs +++ b/frontend/src/app/main/ui/dashboard/fonts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.fonts (:require-macros [app.main.style :as stl]) @@ -43,7 +43,7 @@ (mf/with-effect [team] (when team (let [tname (if (:is-default team) - (tr "dashboard.your-penpot") + (tr "dashboard.personal-projects") (:name team))] (case section :fonts (dom/set-html-title (tr "title.dashboard.fonts" tname)) diff --git a/frontend/src/app/main/ui/dashboard/fonts.scss b/frontend/src/app/main/ui/dashboard/fonts.scss index 23829bb299..c8cbcdf808 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.scss +++ b/frontend/src/app/main/ui/dashboard/fonts.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "common/refactor/common-dashboard"; @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index 0b19614858..20f9117316 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.grid (:require-macros [app.main.style :as stl]) @@ -239,12 +239,18 @@ (mf/defc grid-item-metadata* {::mf/private true} [{:keys [file layout]}] - (let [time (ct/timeago (or (:will-be-deleted-at file) - (:modified-at file)))] - [:span {:class (stl/css-case :grid-item-date (= layout :grid) - :list-item-date (= layout :list)) - :title (tr "dashboard.deleted.will-be-deleted-at" time)} - time])) + (let [deleted-at (:will-be-deleted-at file) + date-class (stl/css-case :grid-item-date (= layout :grid) + :list-item-date (= layout :list))] + (if deleted-at + (let [time (ct/timeago deleted-at)] + [:span {:class date-class + :title (tr "dashboard.deleted.will-be-deleted-at" time)} + time]) + (let [time (ct/timeago (:modified-at file))] + [:span {:class date-class + :title (tr "dashboard.grid.last-modified-at" time)} + time])))) (defn create-counter-element [_element file-count] @@ -480,7 +486,8 @@ [:li {:class (stl/css-case :grid-item true :project-thumbnail true :library-item library-view?)} - [:div {:class (stl/css-case :is-selected selected?) + [:div {:class (stl/css-case :is-selected selected? + :grid-item-button true) :ref node-ref :role "button" :title (:name file) diff --git a/frontend/src/app/main/ui/dashboard/grid.scss b/frontend/src/app/main/ui/dashboard/grid.scss index 448600c835..dd23ce7128 100644 --- a/frontend/src/app/main/ui/dashboard/grid.scss +++ b/frontend/src/app/main/ui/dashboard/grid.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @@ -180,6 +180,12 @@ $thumbnail-default-height: px2rem(168); } } +.grid-item-button { + inline-size: 100%; + block-size: 100%; + padding: 0 px2rem(6); +} + .project-thumbnail-actions { align-items: center; display: flex; @@ -328,7 +334,7 @@ $thumbnail-default-height: px2rem(168); // ─── LIBRARY ─────────────────────────────────── .library-thumbnail { - border-radius: $br-4; + border-radius: $br-8; position: relative; overflow: hidden; background-color: var(--color-background-tertiary); @@ -347,6 +353,7 @@ $thumbnail-default-height: px2rem(168); .library-name-block { color: var(--color-foreground-secondary); inline-size: calc(100% - var(--sp-xxl) - var(--sp-s)); + text-align: left; } .library-item-name { @@ -412,9 +419,14 @@ $thumbnail-default-height: px2rem(168); } .library-typography-sample { + display: flex; + justify-content: center; + align-items: center; block-size: px2rem(20); + line-height: 1; margin-inline-end: var(--sp-xs); inline-size: px2rem(20); + overflow: hidden; } // ─── MISC ────────────────────────────────────── diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index a279c62420..882adcbb35 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.import (:require-macros [app.main.style :as stl]) @@ -15,11 +15,20 @@ [app.main.data.event :as ev] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] + [app.main.repo :as rp] [app.main.store :as st] [app.main.ui.components.file-uploader :refer [file-uploader]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.checkbox :refer [checkbox*]] + [app.main.ui.ds.controls.select :refer [select*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] [app.main.ui.ds.product.loader :refer [loader*]] [app.main.ui.icons :as deprecated-icon] - [app.main.ui.notifications.context-notification :refer [context-notification]] [app.main.worker :as mw] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] @@ -54,7 +63,7 @@ {::mf/forward-ref true} [{:keys [project-id on-finish-import]} external-ref] (let [on-file-selected (use-import-file project-id on-finish-import)] - [:form.import-file {:aria-hidden "true"} + [:form {:aria-hidden "true"} [:& file-uploader {:accept ".penpot,.zip" :multi true :ref external-ref @@ -156,6 +165,19 @@ (and (= :import-ready (:status item)) (not (:deleted item)))) +(defn- has-unresolved? + "Return true if a file-resolution has any :pending needing user choice." + [file-resolution] + (some? (seq (:pending file-resolution)))) + +(defn- count-auto-linked + "Count auto-linked libraries across all file resolutions." + [resolution] + (reduce-kv (fn [acc _ {:keys [done]}] + (+ acc (count done))) + 0 + resolution)) + (defn- analyze-entries [state entries] (let [features (get @st/state :features)] @@ -173,7 +195,7 @@ (swap! state update-with-analyze-result message)))))) (defn- import-files - [state project-id entries] + [state library-resolution-data* project-id entries] (st/emit! (ev/event {::ev/name "import-files" :num-files (count entries)})) @@ -183,27 +205,40 @@ :project-id project-id :files entries :features features}) - (rx/filter (comp uuid? :file-id)) + (rx/filter some?) (rx/subs! (fn [message] - (swap! state update-entry-status message)))))) + ;; Capture library-resolution data if present (same for all + ;; entries from the same zip, so first one wins) + (if-let [resolution (-> (:libraries-resolution message) + (not-empty))] + (reset! library-resolution-data* resolution) + (swap! state update-entry-status message))))))) (mf/defc import-entry* {::mf/memo true ::mf/private true} - [{:keys [entries entry edition can-be-deleted importing? on-edit on-change on-delete]}] + [{:keys [entries entry edition can-be-deleted is-progress on-edit on-change on-delete]}] (let [status (:status entry) ;; FIXME: rename to format format (:type entry) loading? (or (= :analyze status) (= :import-progress status) - (and importing? (= :import-ready status))) + (and is-progress (= :import-ready status))) analyze-error? (= :analyze-error status) import-success? (= :import-success status) import-error? (= :import-error status) import-ready? (= :import-ready status) + level (cond + import-success? :success + import-ready? :success + import-error? :error + analyze-error? :error + loading? nil + :else :default) + is-shared? (:shared entry) progress (:progress entry) @@ -251,46 +286,65 @@ :editable (and import-ready? (not editing?)))} [:div {:class (stl/css :file-name)} - (if loading? - [:> loader* {:width 16 :title (tr "labels.loading")}] - [:div {:class (stl/css-case - :file-icon true - :icon-fill import-ready?)} - (cond - import-ready? deprecated-icon/logo-icon - import-error? deprecated-icon/close - import-success? deprecated-icon/tick - analyze-error? deprecated-icon/close)]) + (when loading? [:> loader* {:width 26 :title (tr "labels.loading")}]) (if editing? [:div {:class (stl/css :file-name-edit)} [:input {:type "text" :auto-focus true + :class (stl/css :file-name-input) + ;;TODO: Add translation for aria-label + :aria-label "File name" :default-value (:name entry) :on-key-press on-edit-key-press :on-blur on-edit-blur}]] [:div {:class (stl/css :file-name-label)} - (:name entry) - (when ^boolean is-shared? - [:span {:class (stl/css :icon)} - deprecated-icon/library])]) - - [:div {:class (stl/css :edit-entry-buttons)} - (when ^boolean editable? - [:button {:on-click on-edit'} deprecated-icon/curve]) - (when ^boolean can-be-deleted - [:button {:on-click on-delete'} deprecated-icon/delete])]] + (if loading? + [:> text* {:class (stl/css :file-name-label) + :as "span" + :typography t/body-medium} + (:name entry) + (when ^boolean is-shared? + [:> icon* {:icon-id i/library :class (stl/css :file-label-icon)}])] + [:> context-notification* + {:level level + :appearance :ghost + :class (stl/css :file-name-notification)} + [:> text* {:class (stl/css :file-name-label) + :as "span" + :typography t/body-medium} + (:name entry) + (when ^boolean is-shared? + [:> icon* {:icon-id i/library :class (stl/css :file-label-icon)}])]])]) + (when ^boolean (or editable? can-be-deleted) + [:div {:class (stl/css :edit-entry-buttons)} + (when ^boolean editable? + [:> icon-button* {:on-click on-edit' + :variant "ghost" + :icon-size "s" + :aria-label (tr "labels.edit") + :icon i/curve}]) + (when ^boolean can-be-deleted + [:> icon-button* {:on-click on-delete' + :variant "ghost" + :icon-size "s" + :aria-label (tr "labels.delete") + :icon i/delete}])])] (cond analyze-error? - [:div {:class (stl/css :error-message)} + [:> text* {:class (stl/css :error-message) + :as "span" + :typography t/body-small} (if (some? (:error entry)) (tr (:error entry)) (tr "dashboard.import.analyze-error"))] import-error? - [:div {:class (stl/css :error-message)} + [:> text* {:class (stl/css :error-message) + :as "span" + :typography t/body-small} (if (some? (:error entry)) (tr (:error entry)) (tr "labels.error"))] @@ -318,6 +372,327 @@ (fn [] (mapv #(assoc % :status :analyze) entries))) +(defn- link-files-to-library! + "Call the link-file-to-library RPC for each file-id with the given + library-id. Returns an observable that completes when all links are done." + [file-ids library-id] + (->> (rx/from file-ids) + (rx/merge-map (fn [file-id] + (->> (rp/cmd! :link-file-to-library + {:file-id file-id + :library-id library-id}) + (rx/catch (fn [cause] + (log/error :hint "failed to link library" + :file-id file-id + :library-id library-id + :cause cause) + (rx/of nil)))))))) + +(mf/defc library-resolution* + {::mf/private true} + [{:keys [unresolved-file selection on-select]}] + (let [candidates (:pending unresolved-file) + disconnected* (mf/use-state #{}) + disconnected (deref disconnected*) + on-change-disconnected + (mf/use-fn + (fn [id] + (swap! disconnected* + (fn [s] + (if (contains? s id) (disj s id) (conj s id))))))] + + ;; Pre-select first candidate for each library + (mf/with-effect [candidates] + (doseq [{:keys [id candidates]} candidates] + (when-not (contains? selection id) + (when-let [first-c (first candidates)] + (on-select id (str (:id first-c))))))) + + [:div {:class (stl/css :library-resolution)} + [:> text* {:class (stl/css :library-resolution-message) + :as "p" + :typography t/body-large} + "Some libraries couldn't be linked automatically. Select the correct library for each:"] + + + [:table {:class (stl/css :library-resolution-table)} + [:thead + [:tr {:class (stl/css :library-resolution-header)} + [:th {:class (stl/css :library-origin-name)} + [:> icon* {:icon-id i/library + :class (stl/css :library-resolution-icon) + :size "s"}] + "original library"] + [:th {:class (stl/css :library-resolution-arrow)}] + [:th {:class (stl/css :library-resolution-connection)} + [:> icon* {:icon-id i/library + :class (stl/css :library-resolution-icon) + :size "s"}] + "connect to"]]] + [:tbody {:class (stl/css :library-resolution-body)} + (for [{:keys [id name candidates]} candidates] + (let [options (mapv (fn [c] + {:id (str (:id c)) + :label (str (:name c) " (" (:project-name c) ")")}) + candidates) + selected (get selection id) + is-conected (not (contains? disconnected id))] + [:tr {:class (stl/css :library-resolution-item) + :key (dm/str id)} + [:td {:class (stl/css :library-resolution-item-name)} + [:> checkbox* {:id (dm/str id) + :label name + :checked is-conected + :on-change #(on-change-disconnected id)}]] + [:td {:class (stl/css :library-resolution-arrow)} + [:> icon* {:icon-id i/row + :size "m"}]] + [:td + (if is-conected + [:> select* {:options options + :class (stl/css :library-resolution-select) + :default-selected (or (some-> selected str) "") + :has-portal true + :on-change (partial on-select id)}] + + [:> text* {:class (stl/css :library-resolution-no-selection) + :as "span" + :typography t/body-small} + (let [selected-c (or (some #(when (= (str (:id %)) selected) %) candidates) + (first candidates))] + (dm/str (:name selected-c) " (" (:project-name selected-c) ")"))])]]))]]])) + +(mf/defc library-resolution-summary-file* + {::mf/private true} + [{:keys [resolution-file selection]}] + (let [done (:done resolution-file) + pending (:pending resolution-file)] + [:div {:class (stl/css :summary-file)} + [:div {:class (stl/css :summary-file-header)} + [:> icon* {:icon-id i/document + :class (stl/css :summary-file-icon) + :size "s"}] + [:> text* {:class (stl/css :summary-file-name) + :as "span" + :typography t/body-medium} + (:name resolution-file)]] + + (when (seq done) + [:div {:class (stl/css :summary-section)} + [:ul {:class (stl/css :summary-list)} + (for [{:keys [name]} done] + [:li {:class (stl/css :summary-list-item) + :key (dm/str name)} + [:span {:class (stl/css :summary-item-name)} name] + [:span {:class (stl/css :summary-linked-badge)} + [:> icon* {:icon-id i/status-tick + :class (stl/css :summary-badge-icon) + :size "s"}] + (tr "dashboard.import.summary.linked")]])]]) + + (when (seq pending) + [:div {:class (stl/css :summary-section)} + [:div {:class (stl/css :summary-section-header)} + ;; TODO: Add translation for this string + + [:> text* {:as "span" + :class (stl/css :summary-section-title) + :typography t/headline-small} + "linked manually"]] + [:ul {:class (stl/css :summary-list)} + [:li {:class (stl/css :summary-list-item) + :key "summary-list-header"} + [:span {:class (stl/css :summary-item-name-header)} + "Original"] + + [:span {:class (stl/css :summary-item-name-header)} + "New"]] + (for [{:keys [id name] :as cand} pending] + (let [selected-id (get selection id) + selected-c (when selected-id + (d/seek #(= (str (:id %)) (str selected-id)) (:candidates cand)))] + [:li {:class (stl/css :summary-list-item) + :key (dm/str id)} + [:span {:class (stl/css :summary-item-name)} name] + [:> icon* {:icon-id i/row + :size "m" + :class (stl/css :summary-linked-arrow)}] + (if selected-c + [:span {:class (stl/css :summary-linked-info)} + [:span {:class (stl/css :summary-linked-name)} + (:name selected-c)] + [:span {:class (stl/css :summary-linked-project)} + (:project-name selected-c)]] + [:span {:class (stl/css :summary-no-selection)} + (tr "dashboard.import.summary.no-selection")])]))]])])) + +(mf/defc library-resolution-summary* + {::mf/private true} + [{:keys [resolution selection]}] + [:div {:class (stl/css :library-resolution)} + [:p {:class (stl/css :library-resolution-message)} + (tr "dashboard.import.resolve-libraries-summary")] + + (for [[file-id resolution-file] resolution] + [:> library-resolution-summary-file* + {:key (dm/str file-id) + :resolution-file resolution-file + :selection selection}])]) + + +;; ── Stage components ──────────────────────────────────────────────── + +(mf/defc import-files-stage* + {::mf/private true} + [{:keys [entries template status errors? import-success-total auto-linked-count + edition on-edit on-change on-delete + on-cancel on-continue on-accept pending-analysis?]}] + [:* + [:div {:class (stl/css :modal-content)} + (when (and (= :analyze status) errors?) + [:> context-notification* + {:level :warning + :class (stl/css :context-notification-error)} + (tr "dashboard.import.import-warning")]) + + (when (= :import-success status) + [:* + [:> context-notification* + {:level (if (zero? import-success-total) :warning :success)} + (tr "dashboard.import.import-message" (i18n/c import-success-total))] + (when (pos? auto-linked-count) + [:> context-notification* + {:level :success} + (tr "dashboard.import.auto-linked-libraries" (i18n/c auto-linked-count))])]) + + (when (= :import-error status) + [:> context-notification* + {:level :error + :class (stl/css :context-notification-error)} + (tr "dashboard.import.import-error.disclaimer")]) + + (when (or (= :import-error status) (and (= :analyze status) errors?)) + [:div {:class (stl/css :import-error-disclaimer)} + [:div (tr "dashboard.import.import-error.message1")] + [:ul {:class (stl/css :import-error-list)} + (for [entry entries] + (when (contains? #{:import-error :analyze-error} (:status entry)) + [:li {:class (stl/css :import-error-list-enry) + :key (dm/str (or (:file-id entry) (:uri entry) (:name entry)))} + [:div (:name entry)] + (when-let [err (:error entry)] + [:div {:class (stl/css :import-error-detail)} + (cond + (and (string? err) + (str/includes? (str/lower err) "check error")) + (tr "dashboard.import.import-error.check-error") + + (and (string? err) + (str/includes? (str/lower err) "corrupt")) + (tr "dashboard.import.import-error.corrupt-file") + + :else + (tr "dashboard.import.import-error.unknown-error"))])]))] + [:div (tr "dashboard.import.import-error.message2")]]) + + (for [entry entries] + [:> import-entry* {:edition edition + :key (dm/str (:uri entry) "/" (:file-id entry)) + :entry entry + :entries entries + :is-progress (= :import-progress status) + :on-edit on-edit + :on-change on-change + :on-delete on-delete + :can-be-deleted (> (count entries) 1)}]) + + (when (some? template) + [:> import-entry* {:entry (assoc template :status status) + :can-be-deleted false}]) + + (when (= :import-progress status) + [:div {:class (stl/css :status-message) + :role "status" + :aria-live "polite"} + (tr "labels.uploading-file")])] + + [:div {:class (stl/css :modal-footer)} + [:div {:class (stl/css :action-buttons)} + (case status + :analyze + [:> button* {:class (stl/css :cancel-button) + :variant "secondary" + :on-click on-cancel} + (tr "labels.cancel")] + + :import-ready + [:> button* {:class (stl/css :accept-btn) + :variant "primary" + :disabled pending-analysis? + :on-click on-continue} + (tr "labels.continue")] + + :import-progress + [:> button* {:class (stl/css :accept-btn) + :variant "primary" + :disabled true + :on-click on-accept} + (tr "labels.accept")] + + (:import-success :import-error) + [:> button* {:class (stl/css :accept-btn) + :variant "primary" + :on-click on-accept} + (tr "labels.accept")])]]]) + +(mf/defc import-library-resolution-stage* + {::mf/private true} + [{:keys [current-unresolved-file selection on-select + visited all-visited? + on-wizard-prev on-wizard-next]}] + [:* + [:div {:class (stl/css :modal-content)} + [:> library-resolution* + {:unresolved-file current-unresolved-file + :selection selection + :on-select on-select}]] + + [:div {:class (stl/css :modal-footer)} + [:div {:class (stl/css :action-buttons)} + (when (seq visited) + [:> button* {:class (stl/css :cancel-button) + :variant "secondary" + :on-click on-wizard-prev} + (tr "labels.previous")]) + [:> button* {:class (stl/css :accept-btn) + :variant "primary" + :on-click on-wizard-next} + (if all-visited? + (tr "labels.next") + (tr "dashboard.import.review-links"))]]]]) + +(mf/defc import-library-summary-stage* + {::mf/private true} + [{:keys [resolution selection visited + on-summary-back on-confirm-library-links]}] + [:* + [:div {:class (stl/css :modal-content)} + [:> library-resolution-summary* + {:resolution resolution + :selection selection}]] + + [:div {:class (stl/css :modal-footer)} + [:div {:class (stl/css :action-buttons)} + (when (seq visited) + [:> button* {:class (stl/css :cancel-button) + :variant "secondary" + :on-click on-summary-back} + (tr "labels.back")]) + [:> button* {:class (stl/css :accept-btn) + :variant "primary" + :on-click on-confirm-library-links} + (tr "dashboard.import.confirm-library-links")]]]]) + (mf/defc import-dialog {::mf/register modal/components ::mf/register-as :import @@ -329,14 +704,49 @@ ;; Revoke all uri's on commonent unmount (fn [] (run! wapi/revoke-uri (map :uri entries)))) - (let [state* (mf/use-state (initialize-state entries)) - entries (deref state*) + (let [state* (mf/use-state (initialize-state entries)) + entries (deref state*) - status* (mf/use-state :analyze) - status (deref status*) + status* (mf/use-state :analyze) + status (deref status*) - edition* (mf/use-state nil) - edition (deref edition*) + edition* (mf/use-state nil) + edition (deref edition*) + + ;; Library resolution data from the backend (auto-linked + multi-match) + resolution* (mf/use-state nil) + resolution (not-empty (deref resolution*)) + + ;; User selection for multi-match candidates: {old-lib-id candidate-id} + selection* (mf/use-state {}) + selection (deref selection*) + + ;; Wizard progression as an ordered "visited" stack of file-ids. + ;; `current-file` is derived: the first unresolved file NOT yet in `visited`. + ;; No numeric step counter — forward = conj, back = pop. + visited* (mf/use-state #(d/ordered-set)) + visited (deref visited*) + + ;; Derived: files that need user resolution (have :candidates) + unresolved-files + (mf/with-memo [resolution] + (when resolution + (reduce-kv (fn [acc _ v] + (if (has-unresolved? v) + (conj acc v) + acc)) + [] + resolution))) + + all-visited? + (mf/with-memo [visited unresolved-files] + (when (seq unresolved-files) + (every? #(contains? visited (:id %)) unresolved-files))) + + ;; Current file shown in the wizard step: first unresolved file not yet visited. + current-unresolved-file + (mf/with-memo [unresolved-files visited] + (d/seek #(not (contains? visited (:id %))) unresolved-files)) continue-entries (mf/use-fn @@ -344,7 +754,7 @@ (fn [] (let [entries (filterv has-status-ready? entries)] (reset! status* :import-progress) - (import-files state* project-id entries)))) + (import-files state* resolution* project-id entries)))) continue-template (mf/use-fn @@ -407,6 +817,52 @@ (continue-template template) (continue-entries)))) + on-confirm-library-links + (mf/use-fn + (mf/deps resolution selection on-finish-import) + (fn [event] + (dom/prevent-default event) + (let [slc selection] + ;; For each file with pending candidates, link it to the selected libraries + (->> (rx/from (seq resolution)) + (rx/merge-map + (fn [[file-id resolution-file]] + (->> (rx/from (:pending resolution-file)) + (rx/merge-map + (fn [{:keys [id]}] + (when-let [selected-lib (get slc id)] + (link-files-to-library! [file-id] selected-lib))))))) + (rx/subs! (constantly nil) + (constantly nil) + (fn [] + (st/emit! (modal/hide)) + (when (fn? on-finish-import) + (on-finish-import)))))))) + + on-wizard-next + (mf/use-fn + (mf/deps current-unresolved-file visited) + (fn [] + (let [file-id (:id current-unresolved-file)] + (swap! visited* conj file-id)))) + + on-wizard-prev + (mf/use-fn + (mf/deps current-unresolved-file) + (fn [] + ;; Remove the current file from visited; it becomes current again after re-render, + ;; because it's no longer in visited. + (let [file-id (:id current-unresolved-file)] + (swap! visited* disj file-id)))) + + on-summary-back + (mf/use-fn + (mf/deps visited) + (fn [] + (let [last-id (last visited)] + (swap! visited* disj last-id) + (reset! status* :library-resolution)))) + on-accept (mf/use-fn (mf/deps on-finish-import) @@ -432,9 +888,25 @@ (zero? (count entries)))) pending-analysis? - (some has-status-analyze? entries)] + (some has-status-analyze? entries) - (mf/with-effect [entries] + auto-linked-count + (if (some? resolution) + (count-auto-linked resolution) + 0) + + manage-on-select + (mf/use-fn + (mf/deps selection) + (fn [old-lib-id candidate-id] + (swap! selection* assoc old-lib-id candidate-id)))] + + (mf/with-effect [visited unresolved-files] + (when (and (seq unresolved-files) + (every? #(contains? visited (:id %)) unresolved-files)) + (reset! status* :library-summary))) + + (mf/with-effect [entries resolution] (cond (some? template) (reset! status* :import-ready) @@ -445,8 +917,11 @@ (and (seq entries) (every? #(= :import-success (:status %)) entries)) - (reset! status* :import-success) - + (reset! status* (if (seq resolution) + (if (seq (filter has-unresolved? (vals resolution))) + :library-resolution + :library-summary) + :import-success)) (and (seq entries) (and (every? #(not= :import-ready (:status %)) entries) (some #(= :import-error (:status %)) entries))) @@ -460,99 +935,50 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} - [:h2 {:class (stl/css :modal-title)} (tr "dashboard.import")] + [:> heading* {:level 2 + :typography t/headline-large + :class (stl/css :modal-title)} + (tr "dashboard.import")] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-cancel + :class (stl/css :modal-close-btn) + :icon i/close}]] - [:button {:class (stl/css :modal-close-btn) - :on-click on-cancel} deprecated-icon/close]] + (case status + (:analyze :import-ready :import-progress :import-success :import-error) + [:> import-files-stage* + {:entries entries + :template template + :status status + :errors? errors? + :import-success-total import-success-total + :auto-linked-count auto-linked-count + :edition edition + :on-edit on-edit + :on-change on-entry-change + :on-delete on-entry-delete + :on-cancel on-cancel + :on-continue on-continue + :on-accept on-accept + :pending-analysis? pending-analysis?}] - [:div {:class (stl/css :modal-content)} - (when (and (= :analyze status) errors?) - [:& context-notification - {:level :warning - :class (stl/css :context-notification-error) - :content (tr "dashboard.import.import-warning")}]) + :library-resolution + [:> import-library-resolution-stage* + {:current-unresolved-file current-unresolved-file + :selection selection + :on-select manage-on-select + :visited visited + :all-visited? all-visited? + :on-wizard-prev on-wizard-prev + :on-wizard-next on-wizard-next}] - (when (= :import-success status) - [:& context-notification - {:level (if (zero? import-success-total) :warning :success) - :content (tr "dashboard.import.import-message" (i18n/c import-success-total))}]) + :library-summary + [:> import-library-summary-stage* + {:resolution resolution + :selection selection + :visited visited + :on-summary-back on-summary-back + :on-confirm-library-links on-confirm-library-links}] - (when (= :import-error status) - [:& context-notification - {:level :error - :class (stl/css :context-notification-error) - :content (tr "dashboard.import.import-error.disclaimer")}]) - - (if (or (= :import-error status) (and (= :analyze status) errors?)) - [:div {:class (stl/css :import-error-disclaimer)} - [:div (tr "dashboard.import.import-error.message1")] - [:ul {:class (stl/css :import-error-list)} - (for [entry entries] - (when (contains? #{:import-error :analyze-error} (:status entry)) - [:li {:class (stl/css :import-error-list-enry) - :key (dm/str (or (:file-id entry) (:uri entry) (:name entry)))} - [:div (:name entry)] - (when-let [err (:error entry)] - [:div {:class (stl/css :import-error-detail)} - ;; Temporary frontend-side error translations to provide more meaningful - ;; messages until backend error handling is improved and standardized. - ;; These mappings are only a short-term workaround and should be removed - ;; once the error handling enhancement is implemented. - ;; https://github.com/penpot/penpot/issues/9884 - (cond - (and (string? err) - (str/includes? (str/lower err) "check error")) - (tr "dashboard.import.import-error.check-error") - - (and (string? err) - (str/includes? (str/lower err) "corrupt")) - (tr "dashboard.import.import-error.corrupt-file") - - :else - (tr "dashboard.import.import-error.unknown-error"))])]))] - [:div (tr "dashboard.import.import-error.message2")]] - - (for [entry entries] - [:> import-entry* {:edition edition - :key (dm/str (:uri entry) "/" (:file-id entry)) - :entry entry - :entries entries - :importing? (= :import-progress status) - :on-edit on-edit - :on-change on-entry-change - :on-delete on-entry-delete - :can-be-deleted (> (count entries) 1)}])) - - (when (some? template) - [:> import-entry* {:entry (assoc template :status status) - :can-be-deleted false}]) - - (when (= :import-progress status) - [:div {:class (stl/css :status-message) - :role "status" - :aria-live "polite"} - (tr "labels.uploading-file")])] - - [:div {:class (stl/css :modal-footer)} - [:div {:class (stl/css :action-buttons)} - (when (= :analyze status) - [:input {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.cancel") - :on-click on-cancel}]) - - (when (= status :import-ready) - [:input {:class (stl/css :accept-btn) - :type "button" - :value (tr "labels.continue") - :disabled pending-analysis? - :on-click on-continue}]) - - (when (or (= :import-success status) - (= :import-error status) - (= :import-progress status)) - [:input {:class (stl/css :accept-btn) - :type "button" - :value (tr "labels.accept") - :disabled (= :import-progress status) - :on-click on-accept}])]]]])) + nil)]])) \ No newline at end of file diff --git a/frontend/src/app/main/ui/dashboard/import.scss b/frontend/src/app/main/ui/dashboard/import.scss index 7ae3efc2c6..6866eb381f 100644 --- a/frontend/src/app/main/ui/dashboard/import.scss +++ b/frontend/src/app/main/ui/dashboard/import.scss @@ -2,254 +2,212 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/spacing.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/z-index.scss" as *; .modal-overlay { - @extend %modal-overlay-base; + display: flex; + justify-content: center; + align-items: center; + position: fixed; + inset-inline-start: 0; + inset-block-start: 0; + block-size: 100%; + inline-size: 100%; + z-index: var(--z-index-set); + background-color: var(--overlay-color); } .modal-container { - @extend %modal-container-base; - + position: relative; display: flex; flex-direction: column; -} - -.modal-header { - margin-bottom: deprecated.$s-24; -} - -.modal-title { - @include deprecated.uppercase-title-typography; - - color: var(--modal-title-foreground-color); -} - -.modal-close-btn { - @extend %modal-close-btn-base; + gap: var(--sp-xxxl); + padding: var(--sp-xxxl); + border-radius: $br-8; + background-color: var(--color-background-primary); + border: $b-2 solid var(--color-background-quaternary); + min-block-size: $sz-192; + inline-size: $sz-512; + max-block-size: px2rem(800); } .modal-content { - @include deprecated.body-small-typography; - flex: 1; overflow: hidden auto; display: grid; grid-template-columns: 1fr; - gap: deprecated.$s-16; - margin-bottom: deprecated.$s-24; - min-height: 40px; + gap: var(--sp-l); + margin-block-end: var(--sp-xxl); + min-block-size: px2rem(40); +} + +.modal-title { + color: var(--color-foreground-primary); +} + +.modal-close-btn { + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: px2rem(6); } .status-message { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); font-style: italic; } .action-buttons { - @extend %modal-action-btns; -} - -.cancel-button { - @extend %modal-cancel-btn; -} - -.accept-btn { - @extend %modal-accept-btn; - - &.danger { - @extend %modal-danger-btn; - } -} - -.modal-scd-msg, -.modal-subtitle, -.modal-msg { - @include deprecated.body-small-typography; - - color: var(--modal-text-foreground-color); - line-height: 1.5; + display: flex; + justify-content: flex-end; + gap: var(--sp-l); } .file-entry { + --file-entry-fg-color: var(--color-foreground-secondary); + display: flex; - - .file-name { - @include deprecated.flex-row; - - .file-icon { - @include deprecated.flex-center; - - height: deprecated.$s-24; - width: deprecated.$s-16; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } - - &.icon-fill svg { - fill: var(--icon-foreground); - } - } - - .file-name-edit { - @extend %input-element; - @include deprecated.body-small-typography; - - flex-grow: 1; - } - - .file-name-label { - @include deprecated.body-small-typography; - - display: flex; - align-items: center; - gap: deprecated.$s-12; - flex-grow: 1; - - .icon { - @include deprecated.flex-center; - - height: deprecated.$s-16; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } - } - } - - .edit-entry-buttons { - @include deprecated.flex-row; - - button { - @extend %button-tertiary; - - width: deprecated.$s-28; - height: deprecated.$s-32; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } - } - } - } - - .error-message, - .progress-message { - display: flex; - align-items: center; - min-height: deprecated.$s-32; - color: var(--modal-text-foreground-color); - } - - .error-message { - align-items: flex-start; - white-space: pre-wrap; - overflow-wrap: anywhere; - } - - .linked-library { - display: flex; - align-items: center; - gap: deprecated.$s-12; - color: var(--modal-text-foreground-color); - - .linked-library-tag { - @include deprecated.flex-center; - - height: deprecated.$s-24; - width: deprecated.$s-24; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } - - &.error { - svg { - stroke: var(--element-foreground-error); - } - } - } - } - - &.loading { - .file-name { - color: var(--modal-text-foreground-color); - } - } - - &.warning { - .file-name { - color: var(--element-foreground-warning); - - .file-icon svg { - stroke: var(--element-foreground-warning); - } - - .file-icon.icon-fill svg { - fill: var(--element-foreground-warning); - } - } - } + flex-direction: column; + gap: var(--sp-m); &.success { - .file-name { - color: var(--modal-text-foreground-color); - - .file-icon svg { - stroke: var(--modal-text-foreground-color); - } - - .file-icon.icon-fill svg { - fill: var(--modal-text-foreground-color); - } - } + --file-entry-fg-color: var(--color-accent-sucess); } &.error { - .file-name { - color: var(--modal-text-foreground-color); - - .file-icon svg { - stroke: var(--modal-text-foreground-color); - } - - .file-icon.icon-fill svg { - fill: var(--modal-text-foreground-color); - } - } + --file-entry-fg-color: var(--color-accent-error); } &.editable { - .file-name { - color: var(--modal-text-foreground-color); + --file-entry-fg-color: var(--color-foreground-primary); + } +} - .file-icon svg { - stroke: var(--modal-text-foreground-color); - } +.error-message, +.progress-message { + display: flex; + align-items: center; + min-block-size: $sz-32; + color: var(--file-entry-fg-color); +} - .file-icon.icon-fill svg { - fill: var(--modal-text-foreground-color); - } +.error-message { + align-items: flex-start; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.linked-library { + display: flex; + align-items: center; + gap: var(--sp-m); + color: var(--file-entry-fg-color); +} + +.linked-library-tag { + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-24; + inline-size: $sz-24; + + svg { + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-16; + inline-size: $sz-16; + color: transparent; + fill: none; + stroke-width: 1px; + stroke: var(--file-entry-fg-color); + } + + &.error { + svg { + stroke: var(--element-foreground-error); } } } +.file-name { + display: flex; + align-items: center; + gap: var(--sp-l); + color: var(--file-entry-fg-color); +} + +.edit-entry-buttons { + display: flex; + align-items: center; + gap: var(--sp-xs); +} + +.file-name-edit { + display: flex; + align-items: center; + block-size: $sz-32; + border: $b-1 solid var(--color-background-tertiary); + color: var(--file-entry-fg-color); + flex-grow: 1; + position: relative; + border-radius: $br-4; + background-color: var(--color-background-tertiary); +} + +.file-name-label { + display: flex; + align-items: center; + gap: var(--sp-m); + flex-grow: 1; +} + +.file-label-icon { + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-16; + inline-size: $sz-16; + color: var(--file-entry-fg-color); +} + +.file-name-input { + @include use-typography("body-medium"); + + --edit-input-background-color: var(--color-background-tertiary); + --edit-input-border-color: transparent; + + block-size: $sz-32; + inline-size: 100%; + padding: px2rem(6); + margin: 0; + border-radius: $br-8; + border: $b-1 solid var(--edit-input-border-color); + color: var(--color-foreground-primary); + background-color: var(--edit-input-background-color); + + &:focus-visible { + --edit-input-background-color: var(--color-background-primary); + --edit-input-border-color: var(--color-accent-primary); + + outline: none; + } +} + .context-notification-error { - --context-notification-bg-color: var(--modal-background-color); + --context-notification-bg-color: var(--color-background-primary); +} + +.file-name-notification { + flex-grow: 1; } .import-error-disclaimer { @@ -266,10 +224,252 @@ } .import-error-detail { - @include deprecated.body-small-typography; - - margin-top: var(--sp-xs); - color: var(--modal-text-foreground-color); + // TODO: Typography does not match any existing DS typography token. + font-family: "worksans", "vazirmatn", sans-serif; + font-size: px2rem(12); + font-weight: 400; + line-height: 1.4; + margin-block-start: var(--sp-xs); + color: var(--color-foreground-secondary); white-space: pre-wrap; overflow-wrap: anywhere; } + +// ################################ +// LIBRARY RESOLUTION +// ################################ + +.library-resolution { + display: flex; + flex-direction: column; + gap: var(--sp-m); +} + +.library-resolution-message { + color: var(--color-foreground-secondary); + margin-block-end: var(--sp-s); +} + +.library-resolution-header { + @include use-typography("body-medium"); + + color: var(--color-foreground-secondary); + display: grid; + grid-template-columns: 1fr 32px 1fr; + border-block-end: $b-1 solid var(--color-foreground-secondary); +} + +.library-origin-name, +.library-resolution-connection { + block-size: $sz-32; + text-align: start; + color: var(--color-foreground-primary); + display: flex; + align-items: center; +} + +.library-resolution-item { + display: grid; + grid-template-columns: 1fr 32px 1fr; + block-size: $sz-32; + margin-block: var(--sp-s); +} + +.library-resolution-icon { + display: flex; + justify-content: center; + align-items: center; + margin-inline-end: var(--sp-s); + color: var(--color-foreground-secondary); +} + +.library-resolution-arrow { + color: var(--color-foreground-secondary); + display: flex; + justify-content: center; + align-items: center; + min-block-size: $sz-32; +} + +.library-resolution-body { + display: flex; + flex-direction: column; + gap: var(--sp-s); +} + +.library-resolution-item-name { + @include use-typography("body-medium"); + + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-s); + color: var(--color-foreground-secondary); + padding-inline-start: var(--sp-s); +} + +.library-resolution-no-selection { + display: flex; + align-items: center; + color: var(--color-foreground-secondary); + padding: var(--sp-s); + margin-inline-start: var(--sp-xxs); + block-size: $sz-32; +} + +// ################################ +// Summary file card +// ################################ + +.summary-file { + display: flex; + flex-direction: column; + gap: var(--sp-s); + padding: var(--sp-m); + border-radius: $br-8; + background: var(--color-background-primary); +} + +.summary-list { + display: flex; + flex-direction: column; + gap: var(--sp-xxs); + list-style: none; + padding: 0; + margin: 0; + border-inline-start: $b-1 solid var(--color-background-quaternary); +} + +.summary-file-header { + display: flex; + align-items: center; + gap: var(--sp-s); + padding-block-end: var(--sp-s); + border-bottom: $b-1 solid var(--color-background-quaternary); +} + +.summary-section-title { + color: var(--color-foreground-primary); +} + +.summary-linked-arrow { + color: var(--color-foreground-secondary); +} + +.summary-file-icon { + color: var(--color-foreground-secondary); + flex-shrink: 0; +} + +.summary-file-name { + color: var(--color-foreground-primary); +} + +// Section within a file (auto-linked or user selection) +.summary-section { + display: flex; + flex-direction: column; + gap: var(--sp-xs); + padding-inline-start: var(--sp-s); +} + +.summary-section-header { + display: flex; + align-items: center; + gap: px2rem(6); + padding: var(--sp-xs) 0; +} + +.summary-list-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-s); + padding: var(--sp-xs) var(--sp-s); + border-radius: $br-4; + + &:hover { + background: var(--color-background-secondary); + } +} + +.summary-item-name { + color: var(--color-foreground-primary); + flex: 1; + min-inline-size: 0; + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.summary-item-name-header { + color: var(--color-foreground-secondary); + flex: 1; + min-inline-size: 0; + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// Auto-linked badge +.summary-linked-badge { + display: inline-flex; + align-items: center; + gap: var(--sp-xs); + padding: var(--sp-xxs) var(--sp-s); + border-radius: $br-12; + background: var(--color-accent-success-bg); + color: var(--color-accent-success); + font-size: px2rem(11); + font-weight: 500; + flex-shrink: 0; +} + +.summary-badge-icon { + color: var(--color-accent-success); +} + +// User-selected library info +.summary-linked-info { + display: flex; + align-items: center; + gap: var(--sp-xs); + flex-shrink: 0; +} + +.summary-linked-name { + // TODO: Typography does not match any existing DS typography token. + font-family: "worksans", "vazirmatn", sans-serif; + font-size: px2rem(12); + line-height: 1.4; + color: var(--color-foreground-secondary); + font-weight: 500; +} + +.summary-linked-project { + // TODO: Typography does not match any existing DS typography token. + font-family: "worksans", "vazirmatn", sans-serif; + font-weight: 400; + line-height: 1.4; + color: var(--color-foreground-secondary); + padding: var(--sp-xxs) var(--sp-s); + border-radius: $br-12; + background: var(--color-background-quaternary); + font-size: px2rem(11); +} + +// No selection state +.summary-no-selection { + // TODO: Typography does not match any existing DS typography token. + font-family: "worksans", "vazirmatn", sans-serif; + font-size: px2rem(12); + font-weight: 400; + line-height: 1.4; + color: var(--color-foreground-secondary); + font-style: italic; + flex-shrink: 0; +} diff --git a/frontend/src/app/main/ui/dashboard/inline_edition.cljs b/frontend/src/app/main/ui/dashboard/inline_edition.cljs index 496574c484..18f5930c60 100644 --- a/frontend/src/app/main/ui/dashboard/inline_edition.cljs +++ b/frontend/src/app/main/ui/dashboard/inline_edition.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.inline-edition (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/inline_edition.scss b/frontend/src/app/main/ui/dashboard/inline_edition.scss index e12b9110e2..0414167696 100644 --- a/frontend/src/app/main/ui/dashboard/inline_edition.scss +++ b/frontend/src/app/main/ui/dashboard/inline_edition.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/dashboard/layout_toggle.cljs b/frontend/src/app/main/ui/dashboard/layout_toggle.cljs index 75bd938cbe..f62f29870c 100644 --- a/frontend/src/app/main/ui/dashboard/layout_toggle.cljs +++ b/frontend/src/app/main/ui/dashboard/layout_toggle.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.layout-toggle "Reactive, persisted preference for how dashboard files are laid out diff --git a/frontend/src/app/main/ui/dashboard/libraries.cljs b/frontend/src/app/main/ui/dashboard/libraries.cljs index f907d04b53..f0cace6e57 100644 --- a/frontend/src/app/main/ui/dashboard/libraries.cljs +++ b/frontend/src/app/main/ui/dashboard/libraries.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.libraries (:require-macros [app.main.style :as stl]) @@ -52,7 +52,7 @@ (mf/with-effect [team] (let [tname (if (:is-default team) - (tr "dashboard.your-penpot") + (tr "dashboard.personal-projects") (:name team))] (dom/set-html-title (tr "title.dashboard.shared-libraries" tname)))) diff --git a/frontend/src/app/main/ui/dashboard/libraries.scss b/frontend/src/app/main/ui/dashboard/libraries.scss index 18d886db99..adf09d7af1 100644 --- a/frontend/src/app/main/ui/dashboard/libraries.scss +++ b/frontend/src/app/main/ui/dashboard/libraries.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; diff --git a/frontend/src/app/main/ui/dashboard/pin_button.cljs b/frontend/src/app/main/ui/dashboard/pin_button.cljs index 9823665849..f1250f6356 100644 --- a/frontend/src/app/main/ui/dashboard/pin_button.cljs +++ b/frontend/src/app/main/ui/dashboard/pin_button.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.pin-button (:require-macros diff --git a/frontend/src/app/main/ui/dashboard/pin_button.scss b/frontend/src/app/main/ui/dashboard/pin_button.scss index 50592b0e9b..097e7e634d 100644 --- a/frontend/src/app/main/ui/dashboard/pin_button.scss +++ b/frontend/src/app/main/ui/dashboard/pin_button.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/dashboard/placeholder.cljs b/frontend/src/app/main/ui/dashboard/placeholder.cljs index 4161db7658..100f1ed4f3 100644 --- a/frontend/src/app/main/ui/dashboard/placeholder.cljs +++ b/frontend/src/app/main/ui/dashboard/placeholder.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.placeholder (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/placeholder.scss b/frontend/src/app/main/ui/dashboard/placeholder.scss index cf17521433..43d95e77d7 100644 --- a/frontend/src/app/main/ui/dashboard/placeholder.scss +++ b/frontend/src/app/main/ui/dashboard/placeholder.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "./grid.scss" as g; diff --git a/frontend/src/app/main/ui/dashboard/project_menu.cljs b/frontend/src/app/main/ui/dashboard/project_menu.cljs index 00db6a7918..ea0a1ab8e9 100644 --- a/frontend/src/app/main/ui/dashboard/project_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/project_menu.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.project-menu (:require diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index 542395caa7..4a729b1615 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.projects (:require-macros [app.main.style :as stl]) @@ -109,6 +109,10 @@ team-id (get team :id) file-count (or (:count project) 0) + + loading? (and (pos? (:count project)) + (empty? files)) + is-draft? (:is-default project) empty? (and (not can-edit) (= 0 file-count)) @@ -292,7 +296,7 @@ [:> line-grid* {:project project :team team - :files files + :files (if loading? nil files) :create-fn create-file :can-edit can-edit :limit limit @@ -313,7 +317,7 @@ (l/derived :recent-files st/state)) (mf/defc projects-section* - [{:keys [team projects profile]}] + [{:keys [team projects profile layout on-layout-change]}] (let [team-id (get team :id) @@ -334,14 +338,6 @@ show-deleted? (:can-edit permisions) - layout* (hooks/use-persisted-state lt/layout-key lt/default-layout) - layout (deref layout*) - - on-layout-change - (mf/use-fn - (fn [value] - (reset! layout* (keyword value)))) - projects (mf/with-memo [projects] (->> projects @@ -361,7 +357,7 @@ (mf/with-effect [team] (let [tname (if (:is-default team) - (tr "dashboard.your-penpot") + (tr "dashboard.personal-projects") (:name team))] (dom/set-html-title (tr "title.dashboard.projects" tname)))) diff --git a/frontend/src/app/main/ui/dashboard/projects.scss b/frontend/src/app/main/ui/dashboard/projects.scss index 774d8ee3fa..ffadadf286 100644 --- a/frontend/src/app/main/ui/dashboard/projects.scss +++ b/frontend/src/app/main/ui/dashboard/projects.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "common/refactor/common-dashboard"; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/dashboard/search.cljs b/frontend/src/app/main/ui/dashboard/search.cljs index bd52a5fa57..bce8ccc0d6 100644 --- a/frontend/src/app/main/ui/dashboard/search.cljs +++ b/frontend/src/app/main/ui/dashboard/search.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.search (:require-macros [app.main.style :as stl]) @@ -44,7 +44,7 @@ (mf/with-effect [team] (when team (let [tname (if (:is-default team) - (tr "dashboard.your-penpot") + (tr "dashboard.personal-projects") (:name team))] (dom/set-html-title (tr "title.dashboard.search" tname))))) diff --git a/frontend/src/app/main/ui/dashboard/search.scss b/frontend/src/app/main/ui/dashboard/search.scss index f3b185aaad..8eea1a9f6e 100644 --- a/frontend/src/app/main/ui/dashboard/search.scss +++ b/frontend/src/app/main/ui/dashboard/search.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index b2dfb2f119..f3e0673ceb 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.sidebar (:require-macros [app.main.style :as stl]) @@ -26,6 +26,7 @@ dropdown-menu-item*]] [app.main.ui.components.link :refer [link*]] [app.main.ui.components.organization-avatar :refer [organization-avatar*]] + [app.main.ui.dashboard.check-updates :as dcu] [app.main.ui.dashboard.comments :refer [comments-icon* comments-section]] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] [app.main.ui.dashboard.project-menu :refer [project-menu*]] @@ -371,8 +372,8 @@ [:span {:class (stl/css :my-teams-icon)} [:> raw-svg* {:id penpot-logo-icon-subtle}]] [:span {:class (stl/css :team-text) - :title (tr "dashboard.my-teams")} - (tr "dashboard.my-teams")] + :title (tr "dashboard.other-teams")} + (tr "dashboard.other-teams")] (when (= default-team-id (:default-team-id organization)) tick-icon)] (when (seq organizations) @@ -438,7 +439,7 @@ (when-not (contains? cf/flags :admin-console) [:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon]) - [:span {:class (stl/css :team-text)} (if (contains? cf/flags :admin-console) (tr "dashboard.my-files") (tr "dashboard.your-penpot"))] + [:span {:class (stl/css :team-text)} (tr "dashboard.personal-projects")] (when (= default-team-id (:id team)) tick-icon)] @@ -725,7 +726,7 @@ current-organization (dtm/team->organization team) - ;; Find the "your-penpot" teams, and transform them in organizations. When + ;; Find the "personal-projects" teams, and transform them in organizations. When ;; the selected team is directly accessible but not listed in ;; membership teams, include only its organization so the organization selector can ;; show the current selection without leaking the team into the @@ -835,7 +836,7 @@ [:span {:class (stl/css :my-teams-icon-xxxl)} [:> raw-svg* {:id penpot-logo-icon-subtle}]] [:span {:class (stl/css :team-text)} - (tr "dashboard.my-teams")]] + (tr "dashboard.other-teams")]] [:* [:> organization-avatar* {:organization current-organization :size "xxxl"}] [:span {:class (stl/css :team-text)} @@ -972,7 +973,7 @@ :team-name-no-logo nitrate?)} (when-not nitrate? [:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon]) - [:span {:class (stl/css :team-text)} (if nitrate? (tr "dashboard.my-files") (tr "dashboard.default-team-name"))]] + [:span {:class (stl/css :team-text)} (tr "dashboard.personal-projects")]] (and (contains? cf/flags :subscriptions) (not is-default?) @@ -1256,8 +1257,11 @@ (mf/defc about-penpot-menu* {::mf/private true} - [{:keys [on-close on-pointer-enter on-pointer-leave]}] - (let [version cf/version + [{:keys [on-close on-close-profile on-pointer-enter on-pointer-leave]}] + (let [version cf/version + checking* (mf/use-state false) + checking? (deref checking*) + show-release-notes (mf/use-fn (fn [event] @@ -1275,7 +1279,22 @@ (dom/get-data "eventname"))] (st/emit! (ev/event {::ev/name eventname ::ev/origin "menu:in-app"})) - (dom/open-new-window url))))] + (dom/open-new-window url)))) + + check-for-updates + (mf/use-fn + (mf/deps on-close-profile version) + (fn [event] + (dom/stop-propagation event) + (when-not @checking* + (st/emit! (ev/event {::ev/name "check-for-updates" + ::ev/origin "menu:in-app" + :version (:base version)})) + (dcu/check-for-updates! + (:base version) + {:on-start #(reset! checking* true) + :on-finish #(do (reset! checking* false) + (on-close-profile))}))))] [:> dropdown-menu* {:show true :class (stl/css :sub-menu :about) @@ -1297,7 +1316,22 @@ :data-url "https://penpot.app/terms" :on-click handle-click-url :data-eventname "explore-terms-service-click"} - (tr "auth.terms-of-service")]])) + (tr "auth.terms-of-service")] + (when-not (contains? cf/flags :air-gapped-conf) + [:* + [:hr {:role "separator" :class (stl/css :submenu-separator)}] + [:> dropdown-menu-item* {:class (stl/css-case :submenu-item true + :checking checking?) + :aria-disabled checking? + :can-focus (not checking?) + :on-click check-for-updates} + (if checking? + (tr "labels.checking-for-updates") + (tr "labels.check-for-updates")) + (when checking? + [:> icon* {:icon-id i/reload + :class (stl/css :checking-icon) + :size "s"}])]])])) (mf/defc profile-section* [{:keys [profile team]}] @@ -1392,13 +1426,7 @@ on-sub-menu-pointer-enter (mf/use-fn (fn [_] - (mf/set-ref-val! hovering?* true))) - - on-power-up-click - (mf/use-fn - (fn [] - (st/emit! (ev/event {::ev/name "explore-pricing-click" ::ev/origin "dashboard" :section "sidebar"})) - (dom/open-new-window "https://penpot.app/pricing")))] + (mf/set-ref-val! hovering?* true)))] (mf/with-effect [teams] (when (and (contains? cf/flags :admin-console) @@ -1420,17 +1448,6 @@ [:> subscription-sidebar* {:profile profile}]))) - ;; TODO remove this block when subscriptions is full implemented - (when (contains? cf/flags :subscriptions-old) - [:button {:class (stl/css :upgrade-plan-section) - :on-click on-power-up-click} - [:div {:class (stl/css :penpot-free)} - [:span (tr "dashboard.upgrade-plan.penpot-free")] - [:span {:class (stl/css :no-limits)} - (tr "dashboard.upgrade-plan.no-limits")]] - [:div {:class (stl/css :power-up)} - (tr "subscription.dashboard.upgrade-plan.power-up")]]) - (when (and team profile) [:& comments-section {:profile profile @@ -1529,6 +1546,7 @@ :about-penpot [:> about-penpot-menu* {:on-close close-sub-menu + :on-close-profile on-close :on-pointer-enter on-sub-menu-pointer-enter :on-pointer-leave on-menu-pointer-leave}] nil))])) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.scss b/frontend/src/app/main/ui/dashboard/sidebar.scss index cc2e882e51..826d7db7c4 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.scss +++ b/frontend/src/app/main/ui/dashboard/sidebar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/colors.scss" as *; @@ -525,6 +525,26 @@ &:hover { color: var(--menu-foreground-color-hover); } + + &.checking { + color: var(--color-foreground-secondary); + pointer-events: none; + + &:hover { + color: var(--color-foreground-secondary); + background-color: transparent; + } + } +} + +.checking-icon { + flex-shrink: 0; + animation: spin-animation 1s infinite linear; +} + +.submenu-separator { + border-top: $b-1 solid var(--color-background-quaternary); + margin: 0; } .about-penpot { @@ -699,12 +719,13 @@ grid-template-columns: 1fr auto auto; gap: var(--sp-s); height: 100%; - padding: 0 px2rem(10); + padding: 0 0 0 px2rem(10); width: 100%; } .current-organization-no-options { gap: 0; + padding: 0 px2rem(8) 0 px2rem(10); } .current-organization .arrow-icon { @@ -734,11 +755,15 @@ display: flex; justify-content: center; align-items: center; - width: $sz-32; - height: $sz-32; + width: $sz-28; + height: 100%; + padding: 0; + border-radius: 0 $br-8 $br-8 0; &:hover { --icon-stroke: var(--color-accent-primary); + + cursor: pointer; } } diff --git a/frontend/src/app/main/ui/dashboard/subscription.cljs b/frontend/src/app/main/ui/dashboard/subscription.cljs index 3886e15fc7..6314349710 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.cljs +++ b/frontend/src/app/main/ui/dashboard/subscription.cljs @@ -1,4 +1,4 @@ -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.subscription (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/subscription.scss b/frontend/src/app/main/ui/dashboard/subscription.scss index 16fa856de9..c4635b94f1 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.scss +++ b/frontend/src/app/main/ui/dashboard/subscription.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 2baaf90d12..c01ca438c8 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.team (:require-macros [app.main.style :as stl]) @@ -19,6 +19,7 @@ [app.main.data.team :as dtm] [app.main.refs :as refs] [app.main.repo :as rp] + [app.main.router :as rt] [app.main.store :as st] [app.main.ui.alert] [app.main.ui.components.dropdown :refer [dropdown]] @@ -38,6 +39,7 @@ [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.main.ui.ds.foundations.typography.text :refer [text*]] [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] + [app.main.ui.ds.tooltip.tooltip :refer [tooltip*]] [app.main.ui.forms :as fc] [app.main.ui.icons :as deprecated-icon] [app.main.ui.notifications.badge :refer [badge-notification]] @@ -87,6 +89,7 @@ route (mf/deref refs/route) invite-email (-> route :query-params :invite-email) + team-id (:id team) members-section? (= section :dashboard-team-members) settings-section? (= section :dashboard-team-settings) @@ -101,15 +104,20 @@ on-invite-member (mf/use-fn - (mf/deps team invite-email) + (mf/deps team-id invite-email) (fn [] - (st/emit! (dtm/check-and-invite-members {:team-id (:id team) + (st/emit! (dtm/check-and-invite-members {:team-id team-id :origin :team :invite-email invite-email}))))] - (mf/with-effect [team invite-email] - (when invite-email - (on-invite-member))) + ;; Depend on `team-id` (stable) rather than `team` (a map whose + ;; reference changes on every teams/members fetch) and clear + ;; `invite-email` from the URL once consumed, so this can't + ;; keep re-triggering `check-and-invite-members` in a loop. + (mf/with-effect [team-id invite-email] + (when (and team-id invite-email) + (on-invite-member) + (st/emit! (rt/nav (get-in route [:data :name]) {:team-id team-id} {::rt/replace true})))) [:header {:class (stl/css :dashboard-header :team) :data-testid "dashboard-header"} [:div {:class (stl/css :dashboard-title)} @@ -132,11 +140,19 @@ [:a {:on-click on-nav-settings} (tr "labels.settings")]]]] [:div {:class (stl/css :dashboard-buttons)} (when (and (or invitations-section? members-section?) (not-empty invitations)) - [:> button* {:variant "secondary" - :on-click on-invite-member - :disabled (not can-invite?) - :data-testid "invite-member"} - (tr "dashboard.invite-profile")])]])) + (let [invite-button (mf/html + [:> button* {:class (stl/css :invite-button) + :variant "secondary" + :on-click on-invite-member + :disabled (not can-invite?) + :data-testid "invite-member"} + (tr "dashboard.invite-profile")])] + (if can-invite? + invite-button + [:> tooltip* {:content (tr "dashboard.invite-profile-disabled") + :id "invite-member-disabled-tooltip" + :tab-index 0} + invite-button])))]])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; INVITATIONS MODAL @@ -226,6 +242,9 @@ (= :email-domain-is-not-allowed code)) (st/emit! (ntf/error (tr "errors.email-domain-not-allowed")) (modal/hide)) + (and (= :validation type) + (= :insufficient-permissions code)) + (st/emit! (modal/show :no-permission-modal {:type :invite-members})) :else (st/emit! (ntf/error (tr "errors.generic")) @@ -625,7 +644,7 @@ (dom/set-html-title (tr "title.team-members" (if (:is-default team) - (tr "dashboard.your-penpot") + (tr "dashboard.personal-projects") (:name team))))) (mf/with-effect [(:id team)] @@ -896,34 +915,57 @@ (mf/defc select-organization-modal {::mf/register modal/components ::mf/register-as :select-organization-modal} - [{:keys [organizations organizations-allowed current-organization-id on-confirm title-key text-key choose-key placeholder-key accept-key cancel-key info-message-key team-id]}] - (let [valid-organizations (mf/with-memo [organizations] - (remove #(= (:id %) current-organization-id) organizations)) - options (mf/with-memo [valid-organizations organizations-allowed] - (mapv (fn [organization] - (let [organization-id (:id organization) - ;; organizations-allowed is a map of organization-id and a boolean indicating if it is allowed - enabled? (or (nil? organizations-allowed) - (true? (get organizations-allowed organization-id)))] - (cond-> {:id (str organization-id) - :label (:name organization) - :disabled (not enabled?) - :dimmed (not enabled?) - :avatar {:render-fn render-organization-combobox-avatar* - :organization organization - :size "xl"}} - (not enabled?) - (assoc :title (tr "dashboard.team-organization.disabled-organization-tooltip"))))) - valid-organizations)) + [{:keys [organizations organizations-allowed current-organization on-confirm title-key text-key choose-key placeholder-key accept-key cancel-key info-message-key description-key team-id]}] + (let [current-organization-id (:id current-organization) + has-current-org? (some? current-organization) + valid-organizations (mf/with-memo [organizations current-organization-id] + (remove #(= (:id %) current-organization-id) organizations)) + all-organizations (mf/with-memo [organizations current-organization] + (cond-> organizations + (and has-current-org? + (not (some #(= (:id %) current-organization-id) organizations))) + (conj current-organization))) + options (mf/with-memo [valid-organizations organizations-allowed current-organization] + (let [other-options + (mapv (fn [organization] + (let [organization-id (:id organization) + enabled? (or (nil? organizations-allowed) + (true? (get organizations-allowed organization-id)))] + (cond-> {:id (str organization-id) + :label (:name organization) + :disabled (not enabled?) + :dimmed (not enabled?) + :avatar {:render-fn render-organization-combobox-avatar* + :organization organization + :size "xl"}} + (not enabled?) + (assoc :title (tr "dashboard.team-organization.disabled-organization-tooltip"))))) + valid-organizations)] + (if has-current-org? + (into [{:id (str current-organization-id) + :label (:name current-organization) + :avatar {:render-fn render-organization-combobox-avatar* + :organization current-organization + :size "xl"}}] + other-options) + other-options))) - form (fm/use-form :schema schema:organization-form :initial {}) + initial-form (mf/with-memo [has-current-org? current-organization-id] + (if has-current-org? + {:selected-id (str current-organization-id)} + {})) + form (fm/use-form :schema schema:organization-form :initial initial-form) - warning-info* (mf/use-state nil) - warning-info (deref warning-info*) - selected-organization (mf/with-memo [warning-info valid-organizations] - (when warning-info - (d/seek #(= (:id %) (:organization-id warning-info)) valid-organizations))) + warning-info* (mf/use-state nil) + warning-info (deref warning-info*) + selected-organization (mf/with-memo [warning-info all-organizations] + (when warning-info + (d/seek #(= (:id %) (:organization-id warning-info)) all-organizations))) + selected-id (dm/get-in @form [:data :selected-id]) + disabled? (or (not (:valid @form)) + (and has-current-org? + (= (str selected-id) (str current-organization-id)))) on-change (mf/use-fn (mf/deps form team-id) @@ -959,18 +1001,25 @@ [:div {:class (stl/css :modal-content :modal-select-organization-text)} (tr text-key)]) [:div {:class (stl/css :modal-select-organization-body)} - (when info-message-key + (when (or description-key info-message-key) [:div {:class (stl/css :modal-select-organization-info)} - (tr info-message-key)]) + (when description-key + [:div + (tr description-key)]) + (when info-message-key + [:div + (tr info-message-key)])]) [:div {:class (stl/css :modal-select-organization-content)} (tr choose-key)] [:> combobox* {:id "selected-id" :class (stl/css :team-member) :options options :select-only true - :default-selected (or (some-> (get-in @form [:data :selected-id]) str) "") :placeholder (tr placeholder-key) - :on-change on-change}] + :on-change on-change + :default-selected (if has-current-org? + (str current-organization-id) + "")}] ;; Warning for external invitations (when (and warning-info @@ -998,7 +1047,7 @@ {:class (stl/css :accept-btn) :variant "primary" :type "button" - :disabled (not (:valid @form)) + :disabled disabled? :on-click on-confirm'} (tr accept-key)]]]]])) @@ -1199,7 +1248,7 @@ (dom/set-html-title (tr "title.team-invitations" (if (:is-default team) - (tr "dashboard.your-penpot") + (tr "dashboard.personal-projects") (:name team))))) (mf/with-effect [(:id team)] @@ -1479,7 +1528,7 @@ (dom/set-html-title (tr "title.team-webhooks" (if (:is-default team) - (tr "dashboard.your-penpot") + (tr "dashboard.personal-projects") (:name team))))) (mf/with-effect [] @@ -1586,7 +1635,7 @@ (mf/with-effect [team] (dom/set-html-title (tr "title.team-settings" (if (:is-default team) - (tr "dashboard.your-penpot") + (tr "dashboard.personal-projects") (:name team))))) (mf/with-effect [] diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index 14366781c4..2a8b754496 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; @@ -963,7 +963,11 @@ .modal-select-organization-info { @include t.use-typography("body-medium"); + display: flex; + flex-direction: column; + gap: var(--sp-m); color: var(--color-foreground-secondary); + margin-block-end: var(--sp-xxl); } .modal-select-organization-title { @@ -1062,6 +1066,10 @@ } } +.invite-button:disabled { + --button-disabled-bg-color: var(--color-background-tertiary); +} + a { color: var(--modal-link-foreground-color); } diff --git a/frontend/src/app/main/ui/dashboard/team_form.cljs b/frontend/src/app/main/ui/dashboard/team_form.cljs index 21a9708c4d..81803fa4a1 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.cljs +++ b/frontend/src/app/main/ui/dashboard/team_form.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.team-form (:require-macros [app.main.style :as stl]) @@ -152,6 +152,8 @@ (tr "dashboard.no-permission-create-team.message" organization-name)] :delete-team [(tr "dashboard.delete-team") (tr "dashboard.no-permission-delete-team.message" organization-name)] + :invite-members [(tr "modals.invite-team-member.title") + (tr "dashboard.invitations.no-permission")] :no-organizations-create [(tr "dashboard.select-organization-modal.title") (tr "dashboard.no-organization-allows-create-team.message")] :no-organizations-change [(tr "dashboard.change-organization-modal.title") diff --git a/frontend/src/app/main/ui/dashboard/team_form.scss b/frontend/src/app/main/ui/dashboard/team_form.scss index 67a2f68d69..946057fa95 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.scss +++ b/frontend/src/app/main/ui/dashboard/team_form.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/dashboard/templates.cljs b/frontend/src/app/main/ui/dashboard/templates.cljs index b5a12d2dcf..cecb4db1aa 100644 --- a/frontend/src/app/main/ui/dashboard/templates.cljs +++ b/frontend/src/app/main/ui/dashboard/templates.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.templates (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/templates.scss b/frontend/src/app/main/ui/dashboard/templates.scss index 3d80caf43e..eabb3c8976 100644 --- a/frontend/src/app/main/ui/dashboard/templates.scss +++ b/frontend/src/app/main/ui/dashboard/templates.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/z-index.scss" as *; diff --git a/frontend/src/app/main/ui/delete_shared.cljs b/frontend/src/app/main/ui/delete_shared.cljs index 5f62c1930c..6e88fd41ab 100644 --- a/frontend/src/app/main/ui/delete_shared.cljs +++ b/frontend/src/app/main/ui/delete_shared.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.delete-shared (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/delete_shared.scss b/frontend/src/app/main/ui/delete_shared.scss index bacba02d59..ab77bb5fd1 100644 --- a/frontend/src/app/main/ui/delete_shared.scss +++ b/frontend/src/app/main/ui/delete_shared.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/basic-rules.scss" as *; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/ds.cljs b/frontend/src/app/main/ui/ds.cljs index ec122ac47f..319a4c8178 100644 --- a/frontend/src/app/main/ui/ds.cljs +++ b/frontend/src/app/main/ui/ds.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds (:require diff --git a/frontend/src/app/main/ui/ds/_borders.scss b/frontend/src/app/main/ui/ds/_borders.scss index 20e3952f98..438cb30f0b 100644 --- a/frontend/src/app/main/ui/ds/_borders.scss +++ b/frontend/src/app/main/ui/ds/_borders.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/ds/_sizes.scss b/frontend/src/app/main/ui/ds/_sizes.scss index 80ea1af6c8..ad0682c195 100644 --- a/frontend/src/app/main/ui/ds/_sizes.scss +++ b/frontend/src/app/main/ui/ds/_sizes.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/ds/_utils.scss b/frontend/src/app/main/ui/ds/_utils.scss index bf075be1c2..314087e614 100644 --- a/frontend/src/app/main/ui/ds/_utils.scss +++ b/frontend/src/app/main/ui/ds/_utils.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:math"; diff --git a/frontend/src/app/main/ui/ds/buttons/_buttons.scss b/frontend/src/app/main/ui/ds/buttons/_buttons.scss index f34198d621..27bd625edf 100644 --- a/frontend/src/app/main/ui/ds/buttons/_buttons.scss +++ b/frontend/src/app/main/ui/ds/buttons/_buttons.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/buttons/button.cljs b/frontend/src/app/main/ui/ds/buttons/button.cljs index 73b6a65180..c7ea4245ab 100644 --- a/frontend/src/app/main/ui/ds/buttons/button.cljs +++ b/frontend/src/app/main/ui/ds/buttons/button.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.buttons.button (:require-macros diff --git a/frontend/src/app/main/ui/ds/buttons/button.scss b/frontend/src/app/main/ui/ds/buttons/button.scss index e646758b7b..347eb26aaf 100644 --- a/frontend/src/app/main/ui/ds/buttons/button.scss +++ b/frontend/src/app/main/ui/ds/buttons/button.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "./buttons" as *; diff --git a/frontend/src/app/main/ui/ds/buttons/button.stories.jsx b/frontend/src/app/main/ui/ds/buttons/button.stories.jsx index d96d80ccdb..f030431e12 100644 --- a/frontend/src/app/main/ui/ds/buttons/button.stories.jsx +++ b/frontend/src/app/main/ui/ds/buttons/button.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/buttons/buttons.mdx b/frontend/src/app/main/ui/ds/buttons/buttons.mdx index 6ca37ebd7c..5eaf8ca5b5 100644 --- a/frontend/src/app/main/ui/ds/buttons/buttons.mdx +++ b/frontend/src/app/main/ui/ds/buttons/buttons.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as ButtonStories from "./button.stories"; diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.cljs b/frontend/src/app/main/ui/ds/buttons/icon_button.cljs index 272d4288a3..2eeb775288 100644 --- a/frontend/src/app/main/ui/ds/buttons/icon_button.cljs +++ b/frontend/src/app/main/ui/ds/buttons/icon_button.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.buttons.icon-button (:require-macros diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.scss b/frontend/src/app/main/ui/ds/buttons/icon_button.scss index 233bb8bfe7..291c352081 100644 --- a/frontend/src/app/main/ui/ds/buttons/icon_button.scss +++ b/frontend/src/app/main/ui/ds/buttons/icon_button.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.stories.jsx b/frontend/src/app/main/ui/ds/buttons/icon_button.stories.jsx index de37e42a02..9f0c411f83 100644 --- a/frontend/src/app/main/ui/ds/buttons/icon_button.stories.jsx +++ b/frontend/src/app/main/ui/ds/buttons/icon_button.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/colors.scss b/frontend/src/app/main/ui/ds/colors.scss index 9440b47f85..aec1a8b4c3 100644 --- a/frontend/src/app/main/ui/ds/colors.scss +++ b/frontend/src/app/main/ui/ds/colors.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:color"; diff --git a/frontend/src/app/main/ui/ds/controls/checkbox.cljs b/frontend/src/app/main/ui/ds/controls/checkbox.cljs index 97d656040d..c93cb391f4 100644 --- a/frontend/src/app/main/ui/ds/controls/checkbox.cljs +++ b/frontend/src/app/main/ui/ds/controls/checkbox.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.checkbox (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/checkbox.mdx b/frontend/src/app/main/ui/ds/controls/checkbox.mdx index 1292b704fb..3ab83376b0 100644 --- a/frontend/src/app/main/ui/ds/controls/checkbox.mdx +++ b/frontend/src/app/main/ui/ds/controls/checkbox.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as Checkbox from "./checkbox.stories"; diff --git a/frontend/src/app/main/ui/ds/controls/checkbox.scss b/frontend/src/app/main/ui/ds/controls/checkbox.scss index a8046e2ebd..be90b3bb53 100644 --- a/frontend/src/app/main/ui/ds/controls/checkbox.scss +++ b/frontend/src/app/main/ui/ds/controls/checkbox.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/checkbox.stories.jsx b/frontend/src/app/main/ui/ds/controls/checkbox.stories.jsx index 4c4513cd02..17d0bb3792 100644 --- a/frontend/src/app/main/ui/ds/controls/checkbox.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/checkbox.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/controls/combobox.cljs b/frontend/src/app/main/ui/ds/controls/combobox.cljs index d90577fa73..2174d84a78 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.cljs +++ b/frontend/src/app/main/ui/ds/controls/combobox.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.combobox (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/combobox.mdx b/frontend/src/app/main/ui/ds/controls/combobox.mdx index 8b30516547..86b8e11119 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.mdx +++ b/frontend/src/app/main/ui/ds/controls/combobox.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from "@storybook/addon-docs/blocks"; import * as ComboboxStories from "./combobox.stories"; diff --git a/frontend/src/app/main/ui/ds/controls/combobox.scss b/frontend/src/app/main/ui/ds/controls/combobox.scss index fd27555983..b11d7b8330 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.scss +++ b/frontend/src/app/main/ui/ds/controls/combobox.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/combobox.stories.jsx b/frontend/src/app/main/ui/ds/controls/combobox.stories.jsx index 8188b49e24..63882556a9 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/combobox.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/controls/input.cljs b/frontend/src/app/main/ui/ds/controls/input.cljs index 0fa5ca9e26..291b0144dd 100644 --- a/frontend/src/app/main/ui/ds/controls/input.cljs +++ b/frontend/src/app/main/ui/ds/controls/input.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.input (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/ds/controls/input.mdx b/frontend/src/app/main/ui/ds/controls/input.mdx index d15b98ebe1..952f38625d 100644 --- a/frontend/src/app/main/ui/ds/controls/input.mdx +++ b/frontend/src/app/main/ui/ds/controls/input.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as InputStories from "./input.stories"; diff --git a/frontend/src/app/main/ui/ds/controls/input.scss b/frontend/src/app/main/ui/ds/controls/input.scss index a83f6be088..5dcad36017 100644 --- a/frontend/src/app/main/ui/ds/controls/input.scss +++ b/frontend/src/app/main/ui/ds/controls/input.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/spacing.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/input.stories.jsx b/frontend/src/app/main/ui/ds/controls/input.stories.jsx index 86e7b57848..f64d97b3e6 100644 --- a/frontend/src/app/main/ui/ds/controls/input.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/input.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/controls/numeric-input.stories.jsx b/frontend/src/app/main/ui/ds/controls/numeric-input.stories.jsx index ffb5684c99..bef96135cc 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric-input.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/numeric-input.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; import { userEvent, within, expect } from "storybook/test"; diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs index ed7d4c4096..416669041d 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.numeric-input (:require-macros [app.main.style :as stl]) @@ -558,10 +558,8 @@ (mf/deps disabled is-open is-multiple? ref min max nillable default is-token-applied?) (fn [event] (when-not (or disabled is-open is-multiple? is-token-applied?) - (let [node (mf/ref-val ref) - is-focused (and (some? node) (dom/active? node)) - has-token (some? (deref token-applied-name*))] - (when-not (or is-focused has-token) + (let [has-token (some? (deref token-applied-name*))] + (when-not has-token (let [client-x (.-clientX event) parsed (parse-value (str/trim (mf/ref-val raw-value*)) (mf/ref-val last-value*) min max nillable) start-val (or parsed default 0)] @@ -610,7 +608,8 @@ (mf/set-ref-val! drag-state* :idle) (dom/release-pointer event) (when-let [node (mf/ref-val ref)] - (dom/focus! node))) + (dom/focus! node) + (dom/select-text! node))) (when (= state :dragging) (mf/set-ref-val! drag-state* :idle) (dom/release-pointer event) @@ -723,7 +722,7 @@ :id id :class inner-class :placeholder (if is-multiple? - (tr "labels.mixed-values") + (tr "settings.multiple") placeholder) :default-value (fmt/format-number (or (mf/ref-val last-value*) value)) :on-blur handle-blur @@ -840,7 +839,8 @@ (mf/with-effect [handle-unmount] handle-unmount) [:div {:class [class (stl/css-case :input-wrapper true - :resizable (not is-token-applied?))] + :resizable (and (not is-token-applied?) + (not disabled)))] :ref wrapper-ref :on-pointer-down on-scrub-pointer-down :on-pointer-move on-scrub-pointer-move diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.mdx b/frontend/src/app/main/ui/ds/controls/numeric_input.mdx index d9ef739476..b5ee8b12f0 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.mdx +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as InputStories from "./numeric-input.stories"; diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.scss b/frontend/src/app/main/ui/ds/controls/numeric_input.scss index d9fb372647..3f9769912c 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.scss +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/spacing.scss" as *; @@ -23,9 +23,7 @@ position: relative; &.resizable { - &:not(:focus-within) { - cursor: ew-resize; - } + cursor: ew-resize; } &:hover { diff --git a/frontend/src/app/main/ui/ds/controls/radio_buttons.cljs b/frontend/src/app/main/ui/ds/controls/radio_buttons.cljs index 84cbaec699..28967a8991 100644 --- a/frontend/src/app/main/ui/ds/controls/radio_buttons.cljs +++ b/frontend/src/app/main/ui/ds/controls/radio_buttons.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.radio-buttons (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/radio_buttons.mdx b/frontend/src/app/main/ui/ds/controls/radio_buttons.mdx index d40f74d910..6f7db4dfc0 100644 --- a/frontend/src/app/main/ui/ds/controls/radio_buttons.mdx +++ b/frontend/src/app/main/ui/ds/controls/radio_buttons.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as RadioButtons from "./radio_buttons.stories"; diff --git a/frontend/src/app/main/ui/ds/controls/radio_buttons.scss b/frontend/src/app/main/ui/ds/controls/radio_buttons.scss index 2026629ed9..154d6517b3 100644 --- a/frontend/src/app/main/ui/ds/controls/radio_buttons.scss +++ b/frontend/src/app/main/ui/ds/controls/radio_buttons.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/spacing.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/radio_buttons.stories.jsx b/frontend/src/app/main/ui/ds/controls/radio_buttons.stories.jsx index 46766d92e6..946784c938 100644 --- a/frontend/src/app/main/ui/ds/controls/radio_buttons.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/radio_buttons.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/controls/select.cljs b/frontend/src/app/main/ui/ds/controls/select.cljs index 337d9f3b51..3d7a65b9a3 100644 --- a/frontend/src/app/main/ui/ds/controls/select.cljs +++ b/frontend/src/app/main/ui/ds/controls/select.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.select (:require-macros @@ -13,9 +13,11 @@ [app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown* schema:option]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.tooltip.tooltip :refer [tooltip*]] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.keyboard :as kbd] [app.util.object :as obj] + [app.util.timers :as timers] [clojure.string :as str] [rumext.v2 :as mf] [rumext.v2.util :as mfu])) @@ -58,11 +60,12 @@ [:empty-to-end {:optional true} [:maybe :boolean]] [:on-change {:optional true} fn?] [:dropdown-alignment {:optional true} [:maybe [:enum :left :right]]] - [:variant {:optional true} [:maybe [:enum "default" "ghost" "icon-only"]]]]) + [:variant {:optional true} [:maybe [:enum "default" "ghost" "icon-only"]]] + [:has-portal {:optional true} :boolean]]) (mf/defc select* {::mf/schema schema:select} - [{:keys [options class disabled default-selected empty-to-end on-change variant wrapper-class dropdown-alignment] :rest props}] + [{:keys [options class disabled default-selected empty-to-end on-change variant wrapper-class dropdown-alignment has-portal] :rest props}] (let [;; NOTE: we use mfu/bean here for transparently handle ;; options provide as clojure data structures or javascript ;; plain objects and lists. @@ -88,6 +91,9 @@ options-ref (mf/use-ref nil) select-ref (mf/use-ref nil) + container (hooks/use-portal-container :popup) + dropdown-wrapper-ref (mf/use-ref nil) + empty-selected-id? (str/blank? selected-id) @@ -208,10 +214,63 @@ (reset! selected-id* (get-selected-option-id options default-selected))) + ;; Portal mode: click-outside + floating positioning + (mf/with-effect [is-open has-portal] + (when (and is-open has-portal) + (let [handler + (fn [event] + (let [wrapper-node (mf/ref-val select-ref) + dropdown-node (mf/ref-val dropdown-wrapper-ref) + target (dom/get-target event)] + (when (and wrapper-node dropdown-node + (not (dom/child? target wrapper-node)) + (not (dom/child? target dropdown-node))) + (reset! is-open* false) + (reset! focused-id* nil)))) + + calculate + (fn [] + (timers/raf + (fn [] + (when-let [select-node (mf/ref-val select-ref)] + (when-let [dropdown-node (mf/ref-val dropdown-wrapper-ref)] + (let [select-rect (dom/get-bounding-rect select-node) + dropdown-rect (dom/get-bounding-rect dropdown-node) + window-height (.-innerHeight js/window) + space-below (- window-height (:bottom select-rect)) + open-up? (> (:height dropdown-rect) space-below)] + (if open-up? + (let [bottom (+ (- window-height (:top select-rect)) 4)] + (dom/set-css-property! dropdown-node "top" "unset") + (dom/set-css-property! dropdown-node "bottom" (str bottom "px"))) + (let [top (+ (:bottom select-rect) 4)] + (dom/set-css-property! dropdown-node "bottom" "unset") + (dom/set-css-property! dropdown-node "top" (str top "px")))) + (dom/set-css-property! dropdown-node "left" (str (:left select-rect) "px")) + (dom/set-css-property! dropdown-node "width" (str (:width select-rect) "px")) + (dom/set-css-property! dropdown-node "position" "fixed")))))))] + + (.addEventListener js/document "mousedown" handler) + + (let [ro (js/ResizeObserver. (fn [_] (calculate)))] + (when-let [node (mf/ref-val select-ref)] + (.observe ro node)) + + (.addEventListener js/window "resize" calculate) + (.addEventListener js/window "scroll" calculate true) + + (calculate) + + (fn [] + (.removeEventListener js/document "mousedown" handler) + (.disconnect ro) + (.removeEventListener js/window "resize" calculate) + (.removeEventListener js/window "scroll" calculate true)))))) + [:div {:class [wrapper-class (stl/css :select-wrapper)] :on-click on-click :ref select-ref - :on-blur on-blur} + :on-blur (when-not has-portal on-blur)} [:> :button props [:span {:class (stl/css-case :select-header true @@ -241,11 +300,24 @@ :aria-hidden true}]] (when ^boolean is-open - [:> options-dropdown* {:on-click on-option-click - :id listbox-id - :options options - :selected selected-id - :focused focused-id - :align dropdown-alignment - :empty-to-end empty-to-end - :ref set-option-ref}])])) + (if has-portal + (mf/portal + (mf/html + [:> options-dropdown* {:on-click on-option-click + :id listbox-id + :options options + :selected selected-id + :focused focused-id + :align dropdown-alignment + :empty-to-end empty-to-end + :ref set-option-ref + :wrapper-ref dropdown-wrapper-ref}]) + container) + [:> options-dropdown* {:on-click on-option-click + :id listbox-id + :options options + :selected selected-id + :focused focused-id + :align dropdown-alignment + :empty-to-end empty-to-end + :ref set-option-ref}]))])) diff --git a/frontend/src/app/main/ui/ds/controls/select.mdx b/frontend/src/app/main/ui/ds/controls/select.mdx index 828ce69875..0469a64294 100644 --- a/frontend/src/app/main/ui/ds/controls/select.mdx +++ b/frontend/src/app/main/ui/ds/controls/select.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as SelectStories from "./select.stories"; diff --git a/frontend/src/app/main/ui/ds/controls/select.scss b/frontend/src/app/main/ui/ds/controls/select.scss index ea60fc4813..2a6f428fd9 100644 --- a/frontend/src/app/main/ui/ds/controls/select.scss +++ b/frontend/src/app/main/ui/ds/controls/select.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/select.stories.jsx b/frontend/src/app/main/ui/ds/controls/select.stories.jsx index ba21289ddc..81627493bc 100644 --- a/frontend/src/app/main/ui/ds/controls/select.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/select.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/controls/shared/dropdown_navigation.cljs b/frontend/src/app/main/ui/ds/controls/shared/dropdown_navigation.cljs index 3f9fc2fa8b..c06047043c 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/dropdown_navigation.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/dropdown_navigation.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.shared.dropdown-navigation (:require [app.util.dom :as dom] diff --git a/frontend/src/app/main/ui/ds/controls/shared/option.cljs b/frontend/src/app/main/ui/ds/controls/shared/option.cljs index ffed06adbb..8f2ad13319 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/option.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/option.cljs @@ -3,7 +3,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.shared.option (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/shared/option.scss b/frontend/src/app/main/ui/ds/controls/shared/option.scss index 43eb3bc294..c604eb0542 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/option.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/option.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs index 48a1ef84bf..a556f00f0a 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.shared.options-dropdown (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss index ee38eb1124..c961b9e06c 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs b/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs index 39020f88a5..c2cbfe2fbe 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.shared.render-option (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/shared/render_option.scss b/frontend/src/app/main/ui/ds/controls/shared/render_option.scss index 232efb42a5..df6ab611e8 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/render_option.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/render_option.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.cljs b/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.cljs index c90df863c8..73a78da212 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.shared.searchable-options-dropdown (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.scss b/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.scss index cbeae912d8..b018359955 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs b/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs index ce8d98c3ab..4de3bdf3c1 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.shared.token-option (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/shared/token_option.scss b/frontend/src/app/main/ui/ds/controls/shared/token_option.scss index e6e6e017be..50f33638c9 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/token_option.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/token_option.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/switch.cljs b/frontend/src/app/main/ui/ds/controls/switch.cljs index 0593881ab0..cdd1eba259 100644 --- a/frontend/src/app/main/ui/ds/controls/switch.cljs +++ b/frontend/src/app/main/ui/ds/controls/switch.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.switch (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/switch.mdx b/frontend/src/app/main/ui/ds/controls/switch.mdx index 6a935720aa..c71a06ebea 100644 --- a/frontend/src/app/main/ui/ds/controls/switch.mdx +++ b/frontend/src/app/main/ui/ds/controls/switch.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as Switch from "./switch.stories"; diff --git a/frontend/src/app/main/ui/ds/controls/switch.scss b/frontend/src/app/main/ui/ds/controls/switch.scss index b1f1f39f93..3e0b23312e 100644 --- a/frontend/src/app/main/ui/ds/controls/switch.scss +++ b/frontend/src/app/main/ui/ds/controls/switch.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/switch.stories.jsx b/frontend/src/app/main/ui/ds/controls/switch.stories.jsx index 449aa1c5c6..eac96529b6 100644 --- a/frontend/src/app/main/ui/ds/controls/switch.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/switch.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/controls/utilities/hint_message.cljs b/frontend/src/app/main/ui/ds/controls/utilities/hint_message.cljs index b1f0d54e5f..bafb1f872f 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/hint_message.cljs +++ b/frontend/src/app/main/ui/ds/controls/utilities/hint_message.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.utilities.hint-message (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/ds/controls/utilities/hint_message.scss b/frontend/src/app/main/ui/ds/controls/utilities/hint_message.scss index 08979c8be3..11b5e4b580 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/hint_message.scss +++ b/frontend/src/app/main/ui/ds/controls/utilities/hint_message.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/colors.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs b/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs index e3d7162b50..a8e827be21 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs +++ b/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.utilities.input-field (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss b/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss index a295655f26..10de19e494 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss +++ b/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @@ -96,7 +96,8 @@ } &::selection { - background: var(--color-accent-select); + background: var(--color-accent-background-select); + color: var(--color-static-white); } &::placeholder { diff --git a/frontend/src/app/main/ui/ds/controls/utilities/label.cljs b/frontend/src/app/main/ui/ds/controls/utilities/label.cljs index 1295af1433..77b3ddf3d3 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/label.cljs +++ b/frontend/src/app/main/ui/ds/controls/utilities/label.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.utilities.label (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/ds/controls/utilities/label.scss b/frontend/src/app/main/ui/ds/controls/utilities/label.scss index e37b70e456..c579c122b6 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/label.scss +++ b/frontend/src/app/main/ui/ds/controls/utilities/label.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/colors.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs b/frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs index 0b6958069b..befe593d16 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs +++ b/frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.utilities.token-field (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/ds/controls/utilities/token_field.scss b/frontend/src/app/main/ui/ds/controls/utilities/token_field.scss index 765e6d5eae..e897cc93db 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/token_field.scss +++ b/frontend/src/app/main/ui/ds/controls/utilities/token_field.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @@ -22,9 +22,10 @@ column-gap: var(--sp-xs); align-items: center; inline-size: 100%; + block-size: var(--token-field-height); background: var(--token-field-bg-color); border-radius: $br-8; - padding-inline-end: var(--sp-xs); + padding: 0 var(--input-padding-size, var(--sp-s)); outline: $b-1 solid var(--token-field-outline-color); position: relative; diff --git a/frontend/src/app/main/ui/ds/elevations.scss b/frontend/src/app/main/ui/ds/elevations.scss index 65bb63ac23..2172136c3d 100644 --- a/frontend/src/app/main/ui/ds/elevations.scss +++ b/frontend/src/app/main/ui/ds/elevations.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL $el-shadow-dark: 0 0 10px 0 var(--color-shadow-dark); diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.clj b/frontend/src/app/main/ui/ds/foundations/assets/icon.clj index c4ffc47c98..6bbbbee49d 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.clj +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.foundations.assets.icon (:require diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs index d0634e21b1..c4d697fd47 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.foundations.assets.icon (:refer-clojure :exclude [mask drop filter remove]) diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.mdx b/frontend/src/app/main/ui/ds/foundations/assets/icon.mdx index 203669991f..b81bcd16a1 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.mdx +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as IconStories from "./icon.stories" diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.scss b/frontend/src/app/main/ui/ds/foundations/assets/icon.scss index 3525f862f9..c8e2c590e7 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.scss +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .icon { fill: var(--icon-fill-color, none); diff --git a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.clj b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.clj index 29abd280fa..47e88056c8 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.clj +++ b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.foundations.assets.raw-svg (:require diff --git a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs index e856a2a1c5..ccd893f19f 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.foundations.assets.raw-svg (:refer-clojure :exclude [mask]) diff --git a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.mdx b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.mdx index bb599648da..9b859b2173 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.mdx +++ b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from "@storybook/addon-docs/blocks"; import * as RawSvgStories from "./raw_svg.stories"; diff --git a/frontend/src/app/main/ui/ds/foundations/typography.cljs b/frontend/src/app/main/ui/ds/foundations/typography.cljs index a9822ad674..041a716034 100644 --- a/frontend/src/app/main/ui/ds/foundations/typography.cljs +++ b/frontend/src/app/main/ui/ds/foundations/typography.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.foundations.typography) diff --git a/frontend/src/app/main/ui/ds/foundations/typography/heading.cljs b/frontend/src/app/main/ui/ds/foundations/typography/heading.cljs index 579f4c9b63..46c06df814 100644 --- a/frontend/src/app/main/ui/ds/foundations/typography/heading.cljs +++ b/frontend/src/app/main/ui/ds/foundations/typography/heading.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.foundations.typography.heading (:require-macros diff --git a/frontend/src/app/main/ui/ds/foundations/typography/heading.mdx b/frontend/src/app/main/ui/ds/foundations/typography/heading.mdx index deda28980d..8f3c96eff9 100644 --- a/frontend/src/app/main/ui/ds/foundations/typography/heading.mdx +++ b/frontend/src/app/main/ui/ds/foundations/typography/heading.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from "@storybook/addon-docs/blocks"; import * as HeadingStories from "./heading.stories"; diff --git a/frontend/src/app/main/ui/ds/foundations/typography/heading.scss b/frontend/src/app/main/ui/ds/foundations/typography/heading.scss index e4ac6c4d7a..49d51b4c6c 100644 --- a/frontend/src/app/main/ui/ds/foundations/typography/heading.scss +++ b/frontend/src/app/main/ui/ds/foundations/typography/heading.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; .display-typography { diff --git a/frontend/src/app/main/ui/ds/foundations/typography/text.cljs b/frontend/src/app/main/ui/ds/foundations/typography/text.cljs index 74a1f8bb45..e5c476f2fb 100644 --- a/frontend/src/app/main/ui/ds/foundations/typography/text.cljs +++ b/frontend/src/app/main/ui/ds/foundations/typography/text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.foundations.typography.text (:require-macros diff --git a/frontend/src/app/main/ui/ds/foundations/typography/text.mdx b/frontend/src/app/main/ui/ds/foundations/typography/text.mdx index 1ebd42906e..4ad209ab31 100644 --- a/frontend/src/app/main/ui/ds/foundations/typography/text.mdx +++ b/frontend/src/app/main/ui/ds/foundations/typography/text.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from "@storybook/addon-docs/blocks"; import * as TextStories from "./text.stories"; diff --git a/frontend/src/app/main/ui/ds/foundations/typography/text.scss b/frontend/src/app/main/ui/ds/foundations/typography/text.scss index e4ac6c4d7a..49d51b4c6c 100644 --- a/frontend/src/app/main/ui/ds/foundations/typography/text.scss +++ b/frontend/src/app/main/ui/ds/foundations/typography/text.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; .display-typography { diff --git a/frontend/src/app/main/ui/ds/foundations/typography/typography.mdx b/frontend/src/app/main/ui/ds/foundations/typography/typography.mdx index 6b8328fc0b..c526a29d53 100644 --- a/frontend/src/app/main/ui/ds/foundations/typography/typography.mdx +++ b/frontend/src/app/main/ui/ds/foundations/typography/typography.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from "@storybook/addon-docs/blocks"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/foundations/utilities/token/token_status.scss b/frontend/src/app/main/ui/ds/foundations/utilities/token/token_status.scss index 3b8be0b18b..626ebe5110 100644 --- a/frontend/src/app/main/ui/ds/foundations/utilities/token/token_status.scss +++ b/frontend/src/app/main/ui/ds/foundations/utilities/token/token_status.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .token-icon { fill: currentcolor; diff --git a/frontend/src/app/main/ui/ds/helpers.cljs b/frontend/src/app/main/ui/ds/helpers.cljs index 85d2c7ab41..f26294437b 100644 --- a/frontend/src/app/main/ui/ds/helpers.cljs +++ b/frontend/src/app/main/ui/ds/helpers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.helpers "A collection of helpers for exporting them to be used on storybook code." diff --git a/frontend/src/app/main/ui/ds/layers/layer_button.cljs b/frontend/src/app/main/ui/ds/layers/layer_button.cljs index 65e33bea70..cc63d805bd 100644 --- a/frontend/src/app/main/ui/ds/layers/layer_button.cljs +++ b/frontend/src/app/main/ui/ds/layers/layer_button.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.layers.layer-button (:require-macros diff --git a/frontend/src/app/main/ui/ds/layers/layer_button.scss b/frontend/src/app/main/ui/ds/layers/layer_button.scss index 2564e59070..08006e6968 100644 --- a/frontend/src/app/main/ui/ds/layers/layer_button.scss +++ b/frontend/src/app/main/ui/ds/layers/layer_button.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/layout/modal.cljs b/frontend/src/app/main/ui/ds/layout/modal.cljs index b808e7040c..173db626c4 100644 --- a/frontend/src/app/main/ui/ds/layout/modal.cljs +++ b/frontend/src/app/main/ui/ds/layout/modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.layout.modal (:require-macros diff --git a/frontend/src/app/main/ui/ds/layout/modal.mdx b/frontend/src/app/main/ui/ds/layout/modal.mdx index 5898e730d5..c2e637f241 100644 --- a/frontend/src/app/main/ui/ds/layout/modal.mdx +++ b/frontend/src/app/main/ui/ds/layout/modal.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from "@storybook/addon-docs/blocks"; import * as Modal from "./modal.stories"; diff --git a/frontend/src/app/main/ui/ds/layout/modal.scss b/frontend/src/app/main/ui/ds/layout/modal.scss index c08df6810d..cb0e16fa1f 100644 --- a/frontend/src/app/main/ui/ds/layout/modal.scss +++ b/frontend/src/app/main/ui/ds/layout/modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/mixins" as *; diff --git a/frontend/src/app/main/ui/ds/layout/modal.stories.jsx b/frontend/src/app/main/ui/ds/layout/modal.stories.jsx index fbb996a3f5..399747cbeb 100644 --- a/frontend/src/app/main/ui/ds/layout/modal.stories.jsx +++ b/frontend/src/app/main/ui/ds/layout/modal.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/layout/tab_switcher.cljs b/frontend/src/app/main/ui/ds/layout/tab_switcher.cljs index d83164ab98..69c4af899c 100644 --- a/frontend/src/app/main/ui/ds/layout/tab_switcher.cljs +++ b/frontend/src/app/main/ui/ds/layout/tab_switcher.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.layout.tab-switcher (:require-macros diff --git a/frontend/src/app/main/ui/ds/layout/tab_switcher.mdx b/frontend/src/app/main/ui/ds/layout/tab_switcher.mdx index df31a26968..e0881707f6 100644 --- a/frontend/src/app/main/ui/ds/layout/tab_switcher.mdx +++ b/frontend/src/app/main/ui/ds/layout/tab_switcher.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as TabSwitcher from "./tab_switcher.stories"; diff --git a/frontend/src/app/main/ui/ds/layout/tab_switcher.scss b/frontend/src/app/main/ui/ds/layout/tab_switcher.scss index c03a03e047..1949a37576 100644 --- a/frontend/src/app/main/ui/ds/layout/tab_switcher.scss +++ b/frontend/src/app/main/ui/ds/layout/tab_switcher.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; @@ -114,6 +114,7 @@ display: grid; width: 100%; height: 100%; + min-block-size: 0; outline: $b-1 solid var(--tab-panel-outline-color); } diff --git a/frontend/src/app/main/ui/ds/layout/tab_switcher.stories.jsx b/frontend/src/app/main/ui/ds/layout/tab_switcher.stories.jsx index a8242247a5..6506ff1669 100644 --- a/frontend/src/app/main/ui/ds/layout/tab_switcher.stories.jsx +++ b/frontend/src/app/main/ui/ds/layout/tab_switcher.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/mixins.scss b/frontend/src/app/main/ui/ds/mixins.scss index 40adfa2c9e..c285bd6295 100644 --- a/frontend/src/app/main/ui/ds/mixins.scss +++ b/frontend/src/app/main/ui/ds/mixins.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/ds/notifications/actionable.cljs b/frontend/src/app/main/ui/ds/notifications/actionable.cljs index 45bd6563f6..31f45e865a 100644 --- a/frontend/src/app/main/ui/ds/notifications/actionable.cljs +++ b/frontend/src/app/main/ui/ds/notifications/actionable.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.notifications.actionable (:require-macros diff --git a/frontend/src/app/main/ui/ds/notifications/actionable.scss b/frontend/src/app/main/ui/ds/notifications/actionable.scss index ace5123964..63e95b8471 100644 --- a/frontend/src/app/main/ui/ds/notifications/actionable.scss +++ b/frontend/src/app/main/ui/ds/notifications/actionable.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/typography.scss" as *; diff --git a/frontend/src/app/main/ui/ds/notifications/context-notification.stories.jsx b/frontend/src/app/main/ui/ds/notifications/context-notification.stories.jsx index 54b5a3f3a5..3efa4b4c60 100644 --- a/frontend/src/app/main/ui/ds/notifications/context-notification.stories.jsx +++ b/frontend/src/app/main/ui/ds/notifications/context-notification.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/notifications/context_notification.cljs b/frontend/src/app/main/ui/ds/notifications/context_notification.cljs index 3505fe7e1a..eb6425fc23 100644 --- a/frontend/src/app/main/ui/ds/notifications/context_notification.cljs +++ b/frontend/src/app/main/ui/ds/notifications/context_notification.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.notifications.context-notification (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/ds/notifications/notifications.mdx b/frontend/src/app/main/ui/ds/notifications/notifications.mdx index 7ffe6a184e..29ecb01afa 100644 --- a/frontend/src/app/main/ui/ds/notifications/notifications.mdx +++ b/frontend/src/app/main/ui/ds/notifications/notifications.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as ToastStories from "./toast.stories"; diff --git a/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.cljs b/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.cljs index 0a774f33ac..8cb380297b 100644 --- a/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.cljs +++ b/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.notifications.shared.notification-pill (:require-macros @@ -59,5 +59,7 @@ (when detail [:details {:class (stl/css :error-detail)} [:summary {:class (stl/css :error-detail-summary)} (tr "workspace.notification-pill.detail")] - [:div {:class (stl/css :error-detail-content) - :dangerouslySetInnerHTML #js {:__html detail}}]])])) + (if is-html + [:div {:class (stl/css :error-detail-content) + :dangerouslySetInnerHTML #js {:__html detail}}] + [:div {:class (stl/css :error-detail-content)} detail])])])) diff --git a/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.scss b/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.scss index a0229548a6..c2bce2ac57 100644 --- a/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.scss +++ b/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/ds/notifications/toast.cljs b/frontend/src/app/main/ui/ds/notifications/toast.cljs index e1aac97c61..fe64f50514 100644 --- a/frontend/src/app/main/ui/ds/notifications/toast.cljs +++ b/frontend/src/app/main/ui/ds/notifications/toast.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.notifications.toast (:require-macros diff --git a/frontend/src/app/main/ui/ds/notifications/toast.scss b/frontend/src/app/main/ui/ds/notifications/toast.scss index e1a4f18df9..9e5f048411 100644 --- a/frontend/src/app/main/ui/ds/notifications/toast.scss +++ b/frontend/src/app/main/ui/ds/notifications/toast.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/ds/notifications/toast.stories.jsx b/frontend/src/app/main/ui/ds/notifications/toast.stories.jsx index cb6052fcea..7dabedd57e 100644 --- a/frontend/src/app/main/ui/ds/notifications/toast.stories.jsx +++ b/frontend/src/app/main/ui/ds/notifications/toast.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/product/avatar.cljs b/frontend/src/app/main/ui/ds/product/avatar.cljs index 8259d348dc..9c20fc4c91 100644 --- a/frontend/src/app/main/ui/ds/product/avatar.cljs +++ b/frontend/src/app/main/ui/ds/product/avatar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.product.avatar (:require-macros diff --git a/frontend/src/app/main/ui/ds/product/avatar.scss b/frontend/src/app/main/ui/ds/product/avatar.scss index b5c41c1a26..45c103508e 100644 --- a/frontend/src/app/main/ui/ds/product/avatar.scss +++ b/frontend/src/app/main/ui/ds/product/avatar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/ds/product/cta.cljs b/frontend/src/app/main/ui/ds/product/cta.cljs index 1efc556bfd..7f0a426cf0 100644 --- a/frontend/src/app/main/ui/ds/product/cta.cljs +++ b/frontend/src/app/main/ui/ds/product/cta.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.product.cta (:require-macros diff --git a/frontend/src/app/main/ui/ds/product/cta.scss b/frontend/src/app/main/ui/ds/product/cta.scss index 46b46cf52f..c1c4952067 100644 --- a/frontend/src/app/main/ui/ds/product/cta.scss +++ b/frontend/src/app/main/ui/ds/product/cta.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/colors.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/product/empty_placeholder.cljs b/frontend/src/app/main/ui/ds/product/empty_placeholder.cljs index a31e1d1c5d..14fb12612b 100644 --- a/frontend/src/app/main/ui/ds/product/empty_placeholder.cljs +++ b/frontend/src/app/main/ui/ds/product/empty_placeholder.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.product.empty-placeholder (:require-macros diff --git a/frontend/src/app/main/ui/ds/product/empty_placeholder.scss b/frontend/src/app/main/ui/ds/product/empty_placeholder.scss index 98d7482268..7431a8aefe 100644 --- a/frontend/src/app/main/ui/ds/product/empty_placeholder.scss +++ b/frontend/src/app/main/ui/ds/product/empty_placeholder.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/ds/product/empty_state.cljs b/frontend/src/app/main/ui/ds/product/empty_state.cljs index 3dd6ef739f..bad9db3781 100644 --- a/frontend/src/app/main/ui/ds/product/empty_state.cljs +++ b/frontend/src/app/main/ui/ds/product/empty_state.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.product.empty-state (:require-macros diff --git a/frontend/src/app/main/ui/ds/product/empty_state.mdx b/frontend/src/app/main/ui/ds/product/empty_state.mdx index 445af4c157..aaaa54af4b 100644 --- a/frontend/src/app/main/ui/ds/product/empty_state.mdx +++ b/frontend/src/app/main/ui/ds/product/empty_state.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as EmptyState from "./empty_state.stories"; diff --git a/frontend/src/app/main/ui/ds/product/empty_state.scss b/frontend/src/app/main/ui/ds/product/empty_state.scss index 7674b2b7bc..a2eb4f6a5e 100644 --- a/frontend/src/app/main/ui/ds/product/empty_state.scss +++ b/frontend/src/app/main/ui/ds/product/empty_state.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/spacing.scss" as *; diff --git a/frontend/src/app/main/ui/ds/product/empty_state.stories.jsx b/frontend/src/app/main/ui/ds/product/empty_state.stories.jsx index 4d29eeeda7..37043e14b3 100644 --- a/frontend/src/app/main/ui/ds/product/empty_state.stories.jsx +++ b/frontend/src/app/main/ui/ds/product/empty_state.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/product/input_with_meta.cljs b/frontend/src/app/main/ui/ds/product/input_with_meta.cljs index 0b6285a6df..396d724fcb 100644 --- a/frontend/src/app/main/ui/ds/product/input_with_meta.cljs +++ b/frontend/src/app/main/ui/ds/product/input_with_meta.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.product.input-with-meta (:require-macros diff --git a/frontend/src/app/main/ui/ds/product/input_with_meta.mdx b/frontend/src/app/main/ui/ds/product/input_with_meta.mdx index cf1c266f3b..68a22a5f4c 100644 --- a/frontend/src/app/main/ui/ds/product/input_with_meta.mdx +++ b/frontend/src/app/main/ui/ds/product/input_with_meta.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as InputWithMetaStories from "./input_with_meta.stories"; diff --git a/frontend/src/app/main/ui/ds/product/input_with_meta.scss b/frontend/src/app/main/ui/ds/product/input_with_meta.scss index cb81177ac0..97de9e2c5f 100644 --- a/frontend/src/app/main/ui/ds/product/input_with_meta.scss +++ b/frontend/src/app/main/ui/ds/product/input_with_meta.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/spacing.scss" as *; diff --git a/frontend/src/app/main/ui/ds/product/input_with_meta.stories.jsx b/frontend/src/app/main/ui/ds/product/input_with_meta.stories.jsx index 8d7dd175d7..899fc2acc9 100644 --- a/frontend/src/app/main/ui/ds/product/input_with_meta.stories.jsx +++ b/frontend/src/app/main/ui/ds/product/input_with_meta.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/product/loader.cljs b/frontend/src/app/main/ui/ds/product/loader.cljs index 5d5f597545..386d4f2914 100644 --- a/frontend/src/app/main/ui/ds/product/loader.cljs +++ b/frontend/src/app/main/ui/ds/product/loader.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.product.loader (:require-macros diff --git a/frontend/src/app/main/ui/ds/product/loader.scss b/frontend/src/app/main/ui/ds/product/loader.scss index 938faadd8f..586bf13aaa 100644 --- a/frontend/src/app/main/ui/ds/product/loader.scss +++ b/frontend/src/app/main/ui/ds/product/loader.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/ds/product/milestone.cljs b/frontend/src/app/main/ui/ds/product/milestone.cljs index 92a7f32082..cf18456f51 100644 --- a/frontend/src/app/main/ui/ds/product/milestone.cljs +++ b/frontend/src/app/main/ui/ds/product/milestone.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.product.milestone (:require-macros diff --git a/frontend/src/app/main/ui/ds/product/milestone.scss b/frontend/src/app/main/ui/ds/product/milestone.scss index 1ed5492667..18b88b4dba 100644 --- a/frontend/src/app/main/ui/ds/product/milestone.scss +++ b/frontend/src/app/main/ui/ds/product/milestone.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/ds/product/milestone_group.cljs b/frontend/src/app/main/ui/ds/product/milestone_group.cljs index 243d7f8ce1..843d85edec 100644 --- a/frontend/src/app/main/ui/ds/product/milestone_group.cljs +++ b/frontend/src/app/main/ui/ds/product/milestone_group.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.product.milestone-group (:require-macros diff --git a/frontend/src/app/main/ui/ds/product/milestone_group.scss b/frontend/src/app/main/ui/ds/product/milestone_group.scss index 0643a1a15d..2326c3a378 100644 --- a/frontend/src/app/main/ui/ds/product/milestone_group.scss +++ b/frontend/src/app/main/ui/ds/product/milestone_group.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/ds/product/panel_title.cljs b/frontend/src/app/main/ui/ds/product/panel_title.cljs index e69813b8e8..79724cf29e 100644 --- a/frontend/src/app/main/ui/ds/product/panel_title.cljs +++ b/frontend/src/app/main/ui/ds/product/panel_title.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.product.panel-title (:require-macros diff --git a/frontend/src/app/main/ui/ds/product/panel_title.mdx b/frontend/src/app/main/ui/ds/product/panel_title.mdx index a6d7a118b8..89564d3fa6 100644 --- a/frontend/src/app/main/ui/ds/product/panel_title.mdx +++ b/frontend/src/app/main/ui/ds/product/panel_title.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as PanelTitle from "./panel_title.stories"; diff --git a/frontend/src/app/main/ui/ds/product/panel_title.scss b/frontend/src/app/main/ui/ds/product/panel_title.scss index 4bdca9aba8..ae45164878 100644 --- a/frontend/src/app/main/ui/ds/product/panel_title.scss +++ b/frontend/src/app/main/ui/ds/product/panel_title.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/ds/spacing.scss b/frontend/src/app/main/ui/ds/spacing.scss index a9bfdedc2b..ec418ea768 100644 --- a/frontend/src/app/main/ui/ds/spacing.scss +++ b/frontend/src/app/main/ui/ds/spacing.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/ds/storybook.cljs b/frontend/src/app/main/ui/ds/storybook.cljs index 53418eb36b..96fd851d6b 100644 --- a/frontend/src/app/main/ui/ds/storybook.cljs +++ b/frontend/src/app/main/ui/ds/storybook.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.storybook (:require-macros diff --git a/frontend/src/app/main/ui/ds/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip.cljs index f1ca9df6d1..2d9b2c9db7 100644 --- a/frontend/src/app/main/ui/ds/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.tooltip (:require diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index e3d6b153fb..0c35334b4f 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.tooltip.tooltip (:require-macros diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.mdx b/frontend/src/app/main/ui/ds/tooltip/tooltip.mdx index 38d52bce41..f64b489d7f 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.mdx +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as Tooltip from "./tooltip.stories"; diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.scss b/frontend/src/app/main/ui/ds/tooltip/tooltip.scss index 0647a74c3c..7a4daaabe7 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.scss +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.stories.jsx b/frontend/src/app/main/ui/ds/tooltip/tooltip.stories.jsx index 0b19d08d6f..3868ff196a 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.stories.jsx +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/typography.scss b/frontend/src/app/main/ui/ds/typography.scss index 5c63090243..3f1126411e 100644 --- a/frontend/src/app/main/ui/ds/typography.scss +++ b/frontend/src/app/main/ui/ds/typography.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/ds/utilities/date.cljs b/frontend/src/app/main/ui/ds/utilities/date.cljs index e9048e78c7..d82f0c7470 100644 --- a/frontend/src/app/main/ui/ds/utilities/date.cljs +++ b/frontend/src/app/main/ui/ds/utilities/date.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.utilities.date (:require-macros diff --git a/frontend/src/app/main/ui/ds/utilities/date.scss b/frontend/src/app/main/ui/ds/utilities/date.scss index e73fb62164..380496676e 100644 --- a/frontend/src/app/main/ui/ds/utilities/date.scss +++ b/frontend/src/app/main/ui/ds/utilities/date.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .date { color: var(--date-color, var(--color-foreground-secondary)); diff --git a/frontend/src/app/main/ui/ds/utilities/swatch.cljs b/frontend/src/app/main/ui/ds/utilities/swatch.cljs index 63c652bd21..652c5429c4 100644 --- a/frontend/src/app/main/ui/ds/utilities/swatch.cljs +++ b/frontend/src/app/main/ui/ds/utilities/swatch.cljs @@ -3,7 +3,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.utilities.swatch (:require-macros diff --git a/frontend/src/app/main/ui/ds/utilities/swatch.mdx b/frontend/src/app/main/ui/ds/utilities/swatch.mdx index f76629dc99..665613cbae 100644 --- a/frontend/src/app/main/ui/ds/utilities/swatch.mdx +++ b/frontend/src/app/main/ui/ds/utilities/swatch.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from "@storybook/addon-docs/blocks"; import * as SwatchStories from "./swatch.stories"; diff --git a/frontend/src/app/main/ui/ds/utilities/swatch.scss b/frontend/src/app/main/ui/ds/utilities/swatch.scss index 9778c923f7..25d8e04019 100644 --- a/frontend/src/app/main/ui/ds/utilities/swatch.scss +++ b/frontend/src/app/main/ui/ds/utilities/swatch.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/utilities/swatch.stories.jsx b/frontend/src/app/main/ui/ds/utilities/swatch.stories.jsx index 7b24d7a693..ab6c838993 100644 --- a/frontend/src/app/main/ui/ds/utilities/swatch.stories.jsx +++ b/frontend/src/app/main/ui/ds/utilities/swatch.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/z-index.scss b/frontend/src/app/main/ui/ds/z-index.scss index 513ff65454..b30f908580 100644 --- a/frontend/src/app/main/ui/ds/z-index.scss +++ b/frontend/src/app/main/ui/ds/z-index.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL $z-index-auto: auto; $z-index-100: 100; diff --git a/frontend/src/app/main/ui/error_boundary.cljs b/frontend/src/app/main/ui/error_boundary.cljs index 226b87369b..6838ace9c3 100644 --- a/frontend/src/app/main/ui/error_boundary.cljs +++ b/frontend/src/app/main/ui/error_boundary.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.error-boundary "React error boundary components" diff --git a/frontend/src/app/main/ui/exports/assets.cljs b/frontend/src/app/main/ui/exports/assets.cljs index d50e2bf236..f1be8e32a2 100644 --- a/frontend/src/app/main/ui/exports/assets.cljs +++ b/frontend/src/app/main/ui/exports/assets.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL ;; FIXME: rename (ns app.main.ui.exports.assets @@ -218,11 +218,25 @@ theme (or (:theme profile) theme/default) is-default-theme? (= theme/default theme) error? (:error state) + ;; The exporter is at capacity: worth its own wording, so the user + ;; knows retrying later is the thing to do. + busy? (= :queue-full (:error-code state)) healthy? (:healthy? state) detail-visible? (:detail-visible state) widget-visible? (:widget-visible state) progress (:progress state) items (:exports state) + job-id (:job-id state) + status (:status state) + queued? (and (some? job-id) (= "queued" status)) + cancelling? (and (some? job-id) (= "cancelling" status)) + cancelled? (and (some? job-id) (= "cancelled" status)) + ;; Only the wasm backend can actually stop: a browser render holds its + ;; pool slot until playwright gives up. + cancellable? (and (some? job-id) + (= "wasm" (:backend state)) + (:in-progress state) + (not cancelling?)) total (or (:total state) (count items)) complete? (= progress total) circ (* 2 Math/PI 12) @@ -236,6 +250,8 @@ color (cond error? clr/new-danger + (or cancelling? + cancelled?) clr/new-warning healthy? (if is-default-theme? clr/new-primary clr/new-primary-light) @@ -248,11 +264,20 @@ title (cond + busy? (tr "workspace.options.exporting-busy") error? (tr "workspace.options.exporting-object-error") + cancelling? (tr "workspace.options.exporting-cancelling") + cancelled? (tr "workspace.options.exporting-cancelled") + queued? (tr "workspace.options.exporting-queued") complete? (tr "workspace.options.exporting-complete") healthy? (tr "workspace.options.exporting-object") (not healthy?) (tr "workspace.options.exporting-object-slow")) + cancel-export + (mf/use-fn + (fn [] + (st/emit! (de/cancel-export)))) + retry-last-operation (mf/use-fn (fn [] @@ -294,11 +319,25 @@ [:div {:class (stl/css :export-progress-title)} [:div {:class (stl/css :title-text)} title] - (if error? + (cond + error? [:button {:class (stl/css :retry-btn) :on-click retry-last-operation} (tr "workspace.options.retry")] + cancellable? + [:* + [:button {:class (stl/css :retry-btn) + :on-click cancel-export} + (tr "workspace.options.cancel-export")] + [:span {:class (stl/css :progress)} + (dm/str progress " / " total)]] + + ;; A counter for work that is being abandoned says nothing useful. + (or cancelling? cancelled?) + nil + + :else [:span {:class (stl/css :progress)} (dm/str progress " / " total)])] diff --git a/frontend/src/app/main/ui/exports/assets.scss b/frontend/src/app/main/ui/exports/assets.scss index c9764632a2..2780d53ea7 100644 --- a/frontend/src/app/main/ui/exports/assets.scss +++ b/frontend/src/app/main/ui/exports/assets.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/exports/files.cljs b/frontend/src/app/main/ui/exports/files.cljs index 526e05cfef..05621603ee 100644 --- a/frontend/src/app/main/ui/exports/files.cljs +++ b/frontend/src/app/main/ui/exports/files.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.exports.files "The files export dialog/modal" @@ -13,8 +13,14 @@ [app.main.data.exports.files :as fexp] [app.main.data.modal :as modal] [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] [app.main.ui.ds.product.loader :refer [loader*]] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] @@ -41,28 +47,33 @@ [files] (let [files (mapv (fn [file] (assoc file :loading true)) files)] {:status :prepare - :selected :all + :selected :include-libraries :files files})) (mf/defc export-entry* {::mf/private true} [{:keys [file]}] - [:div {:class (stl/css-case - :file-entry true - :loading (:loading file) - :success (:export-success? file) - :error (:export-error? file))} + (let [level (cond + (:export-success? file) :success + (:export-error? file) :error + :else :info)] + [:div {:class (stl/css-case + :file-entry true + :loading (:loading file) + :success (:export-success? file) + :error (:export-error? file))} - [:div {:class (stl/css :file-name)} - (if (:loading file) - [:> loader* {:width 16 - :title (tr "labels.loading")}] - [:span {:class (stl/css :file-icon)} - (cond (:export-success? file) deprecated-icon/tick - (:export-error? file) deprecated-icon/close)]) + (if (:loading file) + [:div {:class (stl/css :file-name)} + [:> loader* {:width 26 + :title (tr "labels.loading")}] + [:> text* {:class (stl/css :file-name-label) + :as "span" + :typography t/body-large} + (:name file)]] - [:div {:class (stl/css :file-name-label)} - (:name file)]]]) + [:> context-notification {:level level + :content (:name file)}])])) (mf/defc export-dialog {::mf/register modal/components @@ -109,6 +120,7 @@ (let [type (-> (dom/get-target event) (dom/get-data "type") (keyword))] + (prn "AAA" selected type) (swap! state* assoc :selected type))))] (mf/with-effect [has-libs?] @@ -119,38 +131,59 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} - [:h2 {:class (stl/css :modal-title)} + [:> heading* {:level 2 + :typography t/headline-large + :class (stl/css :modal-title)} (tr "files-download-modal.title")] - [:button {:class (stl/css :modal-close-btn) - :on-click on-cancel} deprecated-icon/close]] - + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-cancel + :class (stl/css :modal-close-btn) + :icon i/close}]] (cond (= status :prepare) [:* [:div {:class (stl/css :modal-content)} - [:p {:class (stl/css :modal-msg)} (tr "files-download-modal.description-1")] - [:p {:class (stl/css :modal-scd-msg)} (tr "files-download-modal.description-2")] + ;; TODO: Add translation + [:> text* {:as "p" :typography t/body-large :class (stl/css :modal-msg)} + "What do you want to do with linked libraries?"] (for [type fexp/valid-types] [:div {:class (stl/css :export-option true) :key (name type)} [:label {:for (str "export-" type) - :class (stl/css-case :global/checked (= selected type))} + :class (stl/css :export-option-label)} ;; Execution time translation strings: - ;; (tr "files-download-modal.options.all.message") - ;; (tr "files-download-modal.options.all.title") - ;; (tr "files-download-modal.options.detach.message") - ;; (tr "files-download-modal.options.detach.title") - ;; (tr "files-download-modal.options.merge.message") - ;; (tr "files-download-modal.options.merge.title") - [:span {:class (stl/css-case :global/checked (= selected type))} + ;; (tr "files-export-modal.options.include-libraries.title") + ;; (tr "files-export-modal.options.include-libraries.message") + + ;; (tr "files-export-modal.options.merge-libraries.title") + ;; (tr "files-export-modal.options.merge-libraries.message") + + ;; (tr "files-export-modal.options.detach-libraries.title") + ;; (tr "files-export-modal.options.detach-libraries.message") + + ;; (tr "files-export-modal.options.link-later.title") + ;; (tr "files-export-modal.options.link-later.message") + + [:span {:class (stl/css-case + :option-icon-wrapper true + :checked (= selected type))} (when (= selected type) - deprecated-icon/status-tick)] + [:svg {:class (stl/css :option-icon) + :viewBox "0 0 8 8" + :width 8 + :height 8 + :aria-hidden true} + [:circle {:cx 4 :cy 4 :r 4}]])] + [:div {:class (stl/css :option-content)} - [:h3 {:class (stl/css :modal-subtitle)} - (tr (dm/str "files-download-modal.options." (d/name type) ".title"))] - [:p {:class (stl/css :modal-msg)} - (tr (dm/str "files-download-modal.options." (d/name type) ".message"))]] + [:> heading* {:level 3 + :typography t/body-large + :class (stl/css :option-title)} + (tr (dm/str "files-export-modal.options." (d/name type) ".title"))] + [:> text* {:as "p" :typography t/body-large :class (stl/css :modal-msg)} + (tr (dm/str "files-export-modal.options." (d/name type) ".message"))]] [:input {:type "radio" :class (stl/css :option-input) @@ -162,15 +195,15 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.cancel") - :on-click on-cancel}] + [:> button* {:variant "secondary" + :type "button" + :on-click on-cancel} + (tr "labels.cancel")] - [:input {:class (stl/css :accept-btn) - :type "button" - :value (tr "labels.continue") - :on-click on-accept}]]]] + [:> button* {:variant "primary" + :type "button" + :on-click on-accept} + (tr "labels.continue")]]]] (= status :exporting) (let [in-progress? (->> state :files (some :loading))] @@ -180,15 +213,15 @@ [:> export-entry* {:file file :key (dm/str (:id file))}]) (when in-progress? - [:div {:class (stl/css :status-message) - :role "status" - :aria-live "polite"} + [:> text* {:as "span" :typography t/body-large :class (stl/css :status-message) + :role "status" + :aria-live "polite"} (tr "labels.downloading-file")])] [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input {:class (stl/css :accept-btn) - :type "button" - :value (tr "labels.close") - :disabled in-progress? - :on-click on-cancel}]]]]))]])) + [:> button* {:variant "primary" + :type "button" + :disabled in-progress? + :on-click on-cancel} + (tr "labels.close")]]]]))]])) diff --git a/frontend/src/app/main/ui/exports/files.scss b/frontend/src/app/main/ui/exports/files.scss index 8959807951..62cc1deeab 100644 --- a/frontend/src/app/main/ui/exports/files.scss +++ b/frontend/src/app/main/ui/exports/files.scss @@ -2,291 +2,207 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/spacing.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/z-index.scss" as *; // EXPORT MODAL .modal-overlay { - @extend %modal-overlay-base; - - &.transparent { - background-color: transparent; - } + display: flex; + justify-content: center; + align-items: center; + position: fixed; + inset-inline-start: 0; + inset-block-start: 0; + block-size: 100%; + inline-size: 100%; + z-index: var(--z-index-set); + background-color: var(--overlay-color); } .modal-container { - @extend %modal-container-base; - - max-height: calc(10 * deprecated.$s-80); -} - -.modal-header { - margin-bottom: deprecated.$s-24; -} - -.modal-title { - @include deprecated.headline-medium-typography; - - color: var(--modal-title-foreground-color); -} - -.modal-close-btn { - @extend %modal-close-btn-base; + position: relative; + display: flex; + flex-direction: column; + gap: var(--sp-xxxl); + padding: var(--sp-xxxl); + border-radius: $br-8; + background-color: var(--color-background-primary); + border: $b-2 solid var(--color-background-quaternary); + min-block-size: $sz-192; + inline-size: $sz-512; + max-block-size: calc(10 * px2rem(80)); } .modal-content { - @include deprecated.body-small-typography; + display: flex; + flex-direction: column; + gap: var(--sp-l); + margin-block-end: var(--sp-xxl); +} - margin-bottom: deprecated.$s-24; +.modal-content-extended { + gap: var(--sp-xxl); +} - .modal-link { - @include deprecated.body-large-typography; +.modal-title { + color: var(--color-foreground-primary); +} - text-decoration: none; - cursor: pointer; - color: var(--modal-link-foreground-color); - } +.modal-close-btn { + position: absolute; + inset-block-start: var(--sp-m); + inset-inline-end: var(--sp-m); +} - .selection-header { - @include deprecated.flex-row; +.modal-msg { + color: var(--color-foreground-secondary); + margin: 0; +} - height: deprecated.$s-32; - margin-bottom: deprecated.$s-4; - - .selection-btn { - @include deprecated.button-style; - @extend %input-checkbox; - @include deprecated.flex-center; - - height: deprecated.$s-24; - width: deprecated.$s-24; - padding: 0; - margin-left: deprecated.$s-16; - - span { - @extend %checkbox-icon; - } - } - - .selection-title { - @include deprecated.body-large-typography; - - color: var(--modal-text-foreground-color); - } - } - - .selection-wrapper { - position: relative; - width: 100%; - height: fit-content; - } - - .selection-shadow { - width: 100%; - height: 100%; - - &::after { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 50px; - background: linear-gradient(to top, rgb(24 24 26 / 1) 0%, rgb(24 24 26 / 0) 100%); - content: ""; - pointer-events: none; - } - } - - .selection-list { - @include deprecated.flex-column; - - max-height: deprecated.$s-400; - overflow-y: auto; - padding-bottom: deprecated.$s-12; - - .selection-row { - @include deprecated.flex-row; - - background-color: var(--entry-background-color); - min-height: deprecated.$s-40; - border-radius: deprecated.$br-8; - - .selection-btn { - @include deprecated.button-style; - - display: grid; - grid-template-columns: min-content auto 1fr auto auto; - align-items: center; - width: 100%; - height: 10%; - gap: deprecated.$s-8; - padding: 0 deprecated.$s-16; - - .checkbox-wrapper { - @extend %input-checkbox; - @include deprecated.flex-center; - - height: deprecated.$s-24; - width: deprecated.$s-24; - padding: 0; - - .checkobox-tick { - @extend %checkbox-icon; - } - } - - .selection-name { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - - flex-grow: 1; - color: var(--modal-text-foreground-color); - text-align: start; - } - - .selection-scale { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - - min-width: deprecated.$s-108; - padding: deprecated.$s-12; - color: var(--modal-text-foreground-color); - } - - .selection-extension { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - - min-width: deprecated.$s-72; - padding: deprecated.$s-12; - color: var(--modal-text-foreground-color); - } - } - - .image-wrapper { - @include deprecated.flex-center; - - min-height: deprecated.$s-32; - min-width: deprecated.$s-32; - background-color: var(--app-white); - border-radius: deprecated.$br-6; - margin: auto 0; - - img, - svg { - object-fit: contain; - max-height: deprecated.$s-40; - } - } - } - } +.option-content { + display: flex; + flex-direction: column; } .status-message { - @include deprecated.body-small-typography; - - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); font-style: italic; } .action-buttons { - @extend %modal-action-btns; -} - -.cancel-button { - @extend %modal-cancel-btn; -} - -.accept-btn { - @extend %modal-accept-btn; - - &.danger { - @extend %modal-danger-btn; - } -} - -.modal-scd-msg, -.modal-subtitle, -.modal-msg { - @include deprecated.body-large-typography; - - color: var(--modal-text-foreground-color); + display: flex; + justify-content: flex-end; + gap: var(--sp-s); } .export-option { - @extend %input-checkbox; - - width: 100%; + display: flex; align-items: flex-start; + inline-size: 100%; +} - label { - align-items: flex-start; +.export-option-label { + --input-border-color: var(--input-checkbox-border-color-rest); + --input-icon-color: var(--color-background-primary); - .modal-subtitle { - @include deprecated.body-large-typography; + display: flex; + align-items: flex-start; + gap: px2rem(6); + cursor: pointer; + color: var(--color-foreground-primary); - color: var(--modal-title-foreground-color); - padding: 0.25rem 0; - } + &:hover { + --input-border-color: var(--color-accent-primary-muted); } - span { - margin-top: deprecated.$s-8; + &:focus, + &:focus-within { + --input-border-color: var(--color-accent-primary); } } -.option-content { - @include deprecated.flex-column; - @include deprecated.body-large-typography; +.option-icon-wrapper { + --icon-display: none; + --background-color: var(--color-background-quaternary); + + display: flex; + justify-content: center; + align-items: center; + inline-size: px2rem(16); + min-inline-size: px2rem(16); + block-size: px2rem(16); + margin-block-start: px2rem(10); + background-color: var(--background-color); + border: px2rem(1) solid var(--input-border-color); + border-radius: $br-circle; + + &.checked { + --icon-display: block; + --input-border-color: var(--color-background-quaternary); + --input-icon-color: var(--color-background-primary); + --background-color: var(--color-accent-primary); + } + + &:hover { + --input-border-color: var(--color-accent-primary-muted); + } + + &:focus { + --input-border-color: var(--color-accent-primary); + } +} + +.option-icon { + inline-size: px2rem(8); + block-size: px2rem(8); + display: var(--icon-display); + fill: var(--input-icon-color); +} + +.option-input { + margin: 0; } .file-entry { - .file-name { - @include deprecated.flex-row; - - .file-icon { - @include deprecated.flex-center; - - height: deprecated.$s-16; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - stroke: var(--input-foreground); - } - } - - .file-name-label { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - } - } + --file-entry-color: var(--color-foreground-secondary); &.loading { .file-name { - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); } } &.error { .file-name { - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); .file-icon svg { - stroke: var(--modal-text-foreground-color); + stroke: var(--color-foreground-secondary); } } } &.success { .file-name { - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); .file-icon svg { - stroke: var(--modal-text-foreground-color); + stroke: var(--color-foreground-secondary); } } } } + +.file-name { + display: flex; + align-items: center; + gap: var(--sp-m); + + .file-icon { + display: flex; + justify-content: center; + align-items: center; + block-size: px2rem(16); + inline-size: px2rem(16); + color: var(--color-foreground-secondary); + } + + .file-name-label { + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.icon-status-tick { + fill: none; + stroke: var(--color-accent-primary); +} diff --git a/frontend/src/app/main/ui/flex_controls.cljs b/frontend/src/app/main/ui/flex_controls.cljs index baa8207df3..66e2ca27fb 100644 --- a/frontend/src/app/main/ui/flex_controls.cljs +++ b/frontend/src/app/main/ui/flex_controls.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.flex-controls (:require diff --git a/frontend/src/app/main/ui/flex_controls/gap.cljs b/frontend/src/app/main/ui/flex_controls/gap.cljs index 6bcb237821..c297807e2d 100644 --- a/frontend/src/app/main/ui/flex_controls/gap.cljs +++ b/frontend/src/app/main/ui/flex_controls/gap.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.flex-controls.gap (:require diff --git a/frontend/src/app/main/ui/flex_controls/margin.cljs b/frontend/src/app/main/ui/flex_controls/margin.cljs index b8d39c73fb..8638776132 100644 --- a/frontend/src/app/main/ui/flex_controls/margin.cljs +++ b/frontend/src/app/main/ui/flex_controls/margin.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.flex-controls.margin (:require diff --git a/frontend/src/app/main/ui/flex_controls/padding.cljs b/frontend/src/app/main/ui/flex_controls/padding.cljs index 1b125717bd..eb4a9680ef 100644 --- a/frontend/src/app/main/ui/flex_controls/padding.cljs +++ b/frontend/src/app/main/ui/flex_controls/padding.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.flex-controls.padding (:require diff --git a/frontend/src/app/main/ui/formats.cljs b/frontend/src/app/main/ui/formats.cljs index d16c134a30..0655b77de7 100644 --- a/frontend/src/app/main/ui/formats.cljs +++ b/frontend/src/app/main/ui/formats.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.formats (:require diff --git a/frontend/src/app/main/ui/forms.cljs b/frontend/src/app/main/ui/forms.cljs index f6a870f618..b664d5dfe8 100644 --- a/frontend/src/app/main/ui/forms.cljs +++ b/frontend/src/app/main/ui/forms.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.forms (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/forms.scss b/frontend/src/app/main/ui/forms.scss index c576dfcda2..58872f37ed 100644 --- a/frontend/src/app/main/ui/forms.scss +++ b/frontend/src/app/main/ui/forms.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/frame_preview.cljs b/frontend/src/app/main/ui/frame_preview.cljs index 06f55579d4..a2705ea4da 100644 --- a/frontend/src/app/main/ui/frame_preview.cljs +++ b/frontend/src/app/main/ui/frame_preview.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.frame-preview (:require diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index 83b86d932d..3a4e76edeb 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -43,13 +43,12 @@ (defn use-shortcuts [key shortcuts group-key] - (let [custom-shortcuts (mf/deref refs/custom-shortcuts)] - (mf/use-effect - #js [(str key) shortcuts custom-shortcuts] + (mf/use-effect + #js [(str key) shortcuts] + (fn [] + (st/emit! (dsc/push-shortcuts key shortcuts group-key)) (fn [] - (st/emit! (dsc/push-shortcuts key shortcuts group-key)) - (fn [] - (st/emit! (dsc/pop-shortcuts key))))))) + (st/emit! (dsc/pop-shortcuts key)))))) (defn- set-timer [state ms func] diff --git a/frontend/src/app/main/ui/hooks/floating_drag.cljs b/frontend/src/app/main/ui/hooks/floating_drag.cljs index f5f4481923..c6318c2ea2 100644 --- a/frontend/src/app/main/ui/hooks/floating_drag.cljs +++ b/frontend/src/app/main/ui/hooks/floating_drag.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.hooks.floating-drag "Pointer drag hook for floating panels, mirroring the plugin modal drag diff --git a/frontend/src/app/main/ui/hooks/mutable_observer.cljs b/frontend/src/app/main/ui/hooks/mutable_observer.cljs index 41146ef178..00927d115d 100644 --- a/frontend/src/app/main/ui/hooks/mutable_observer.cljs +++ b/frontend/src/app/main/ui/hooks/mutable_observer.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.hooks.mutable-observer (:require diff --git a/frontend/src/app/main/ui/hooks/resize.cljs b/frontend/src/app/main/ui/hooks/resize.cljs index f666b2126e..c8dc452194 100644 --- a/frontend/src/app/main/ui/hooks/resize.cljs +++ b/frontend/src/app/main/ui/hooks/resize.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.hooks.resize (:require diff --git a/frontend/src/app/main/ui/icons.clj b/frontend/src/app/main/ui/icons.clj index 47b55b14ab..4115c38339 100644 --- a/frontend/src/app/main/ui/icons.clj +++ b/frontend/src/app/main/ui/icons.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.icons (:require diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index ee405fa7f8..9e62720d02 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.icons (:refer-clojure :exclude [import mask]) @@ -144,6 +144,9 @@ (def ^:icon group (icon-xref :group)) (def ^:icon gutter-horizontal (icon-xref :gutter-horizontal)) (def ^:icon gutter-vertical (icon-xref :gutter-vertical)) +(def ^:icon handlers-equal (icon-xref :handlers-equal)) +(def ^:icon handlers-independent (icon-xref :handlers-independent)) +(def ^:icon handlers-mirror (icon-xref :handlers-mirror)) (def ^:icon help (icon-xref :help)) (def ^:icon hide (icon-xref :hide)) (def ^:icon history (icon-xref :history)) @@ -219,6 +222,7 @@ (def ^:icon shown (icon-xref :shown)) (def ^:icon size-horizontal (icon-xref :size-horizontal)) (def ^:icon size-vertical (icon-xref :size-vertical)) +(def ^:icon snap (icon-xref :snap)) (def ^:icon snap-nodes (icon-xref :snap-nodes)) (def ^:icon status-alert (icon-xref :status-alert)) (def ^:icon status-tick (icon-xref :status-tick)) diff --git a/frontend/src/app/main/ui/inspect/annotation.cljs b/frontend/src/app/main/ui/inspect/annotation.cljs index e24d705bea..f9d8dfd3c0 100644 --- a/frontend/src/app/main/ui/inspect/annotation.cljs +++ b/frontend/src/app/main/ui/inspect/annotation.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.annotation (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/annotation.scss b/frontend/src/app/main/ui/inspect/annotation.scss index 5a8cac76cd..5171c994f3 100644 --- a/frontend/src/app/main/ui/inspect/annotation.scss +++ b/frontend/src/app/main/ui/inspect/annotation.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/inspect/attributes.cljs b/frontend/src/app/main/ui/inspect/attributes.cljs index 7535709328..2818edf9c1 100644 --- a/frontend/src/app/main/ui/inspect/attributes.cljs +++ b/frontend/src/app/main/ui/inspect/attributes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes.scss b/frontend/src/app/main/ui/inspect/attributes.scss index 10d7943670..8e81c72295 100644 --- a/frontend/src/app/main/ui/inspect/attributes.scss +++ b/frontend/src/app/main/ui/inspect/attributes.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/blur.cljs b/frontend/src/app/main/ui/inspect/attributes/blur.cljs index ba50e6be32..2190bbeb1e 100644 --- a/frontend/src/app/main/ui/inspect/attributes/blur.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/blur.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.blur (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/blur.scss b/frontend/src/app/main/ui/inspect/attributes/blur.scss index fc60a1c3d5..8a5856555b 100644 --- a/frontend/src/app/main/ui/inspect/attributes/blur.scss +++ b/frontend/src/app/main/ui/inspect/attributes/blur.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/common.cljs b/frontend/src/app/main/ui/inspect/attributes/common.cljs index 573c4cd1c1..28e251b2cf 100644 --- a/frontend/src/app/main/ui/inspect/attributes/common.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.common (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/common.scss b/frontend/src/app/main/ui/inspect/attributes/common.scss index b385025fad..8bbc6c7cd5 100644 --- a/frontend/src/app/main/ui/inspect/attributes/common.scss +++ b/frontend/src/app/main/ui/inspect/attributes/common.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/fill.cljs b/frontend/src/app/main/ui/inspect/attributes/fill.cljs index 17d3d477dd..3d0e72e523 100644 --- a/frontend/src/app/main/ui/inspect/attributes/fill.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/fill.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.fill (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/fill.scss b/frontend/src/app/main/ui/inspect/attributes/fill.scss index 8725a1d364..ee7dc9c607 100644 --- a/frontend/src/app/main/ui/inspect/attributes/fill.scss +++ b/frontend/src/app/main/ui/inspect/attributes/fill.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/geometry.cljs b/frontend/src/app/main/ui/inspect/attributes/geometry.cljs index 8a6c255d50..4f99f5dba8 100644 --- a/frontend/src/app/main/ui/inspect/attributes/geometry.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/geometry.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.geometry (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/geometry.scss b/frontend/src/app/main/ui/inspect/attributes/geometry.scss index 8b31afdb0c..64a5aabc1e 100644 --- a/frontend/src/app/main/ui/inspect/attributes/geometry.scss +++ b/frontend/src/app/main/ui/inspect/attributes/geometry.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/layout.cljs b/frontend/src/app/main/ui/inspect/attributes/layout.cljs index 8c2d227f6f..fa0d3dfa0c 100644 --- a/frontend/src/app/main/ui/inspect/attributes/layout.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/layout.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.layout (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/layout.scss b/frontend/src/app/main/ui/inspect/attributes/layout.scss index bcf1afbafc..453903e8c3 100644 --- a/frontend/src/app/main/ui/inspect/attributes/layout.scss +++ b/frontend/src/app/main/ui/inspect/attributes/layout.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/layout_element.cljs b/frontend/src/app/main/ui/inspect/attributes/layout_element.cljs index 11b904d1a7..24b46adcf6 100644 --- a/frontend/src/app/main/ui/inspect/attributes/layout_element.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/layout_element.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.layout-element (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/layout_element.scss b/frontend/src/app/main/ui/inspect/attributes/layout_element.scss index 8c4af89efa..d7994312e7 100644 --- a/frontend/src/app/main/ui/inspect/attributes/layout_element.scss +++ b/frontend/src/app/main/ui/inspect/attributes/layout_element.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/shadow.cljs b/frontend/src/app/main/ui/inspect/attributes/shadow.cljs index 0f092d9b6c..0480178ba4 100644 --- a/frontend/src/app/main/ui/inspect/attributes/shadow.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/shadow.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.shadow (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/shadow.scss b/frontend/src/app/main/ui/inspect/attributes/shadow.scss index 7d641d619a..e0638524b0 100644 --- a/frontend/src/app/main/ui/inspect/attributes/shadow.scss +++ b/frontend/src/app/main/ui/inspect/attributes/shadow.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/stroke.cljs b/frontend/src/app/main/ui/inspect/attributes/stroke.cljs index da2d1acb6a..a153926ffd 100644 --- a/frontend/src/app/main/ui/inspect/attributes/stroke.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/stroke.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.stroke (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/stroke.scss b/frontend/src/app/main/ui/inspect/attributes/stroke.scss index 790a48dc41..3d512f159f 100644 --- a/frontend/src/app/main/ui/inspect/attributes/stroke.scss +++ b/frontend/src/app/main/ui/inspect/attributes/stroke.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/svg.cljs b/frontend/src/app/main/ui/inspect/attributes/svg.cljs index 5ccb3dacee..8344208afc 100644 --- a/frontend/src/app/main/ui/inspect/attributes/svg.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/svg.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.svg (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/svg.scss b/frontend/src/app/main/ui/inspect/attributes/svg.scss index ff14274daf..07424f056e 100644 --- a/frontend/src/app/main/ui/inspect/attributes/svg.scss +++ b/frontend/src/app/main/ui/inspect/attributes/svg.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/text.cljs b/frontend/src/app/main/ui/inspect/attributes/text.cljs index a8565fde26..031647fe39 100644 --- a/frontend/src/app/main/ui/inspect/attributes/text.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.text (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/text.scss b/frontend/src/app/main/ui/inspect/attributes/text.scss index 786ebcc1bf..d76ee08e11 100644 --- a/frontend/src/app/main/ui/inspect/attributes/text.scss +++ b/frontend/src/app/main/ui/inspect/attributes/text.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/variant.cljs b/frontend/src/app/main/ui/inspect/attributes/variant.cljs index 4ff196cf29..b6cfec0b5d 100644 --- a/frontend/src/app/main/ui/inspect/attributes/variant.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/variant.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.variant (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/variant.scss b/frontend/src/app/main/ui/inspect/attributes/variant.scss index 7cb734eaad..13472fb2e5 100644 --- a/frontend/src/app/main/ui/inspect/attributes/variant.scss +++ b/frontend/src/app/main/ui/inspect/attributes/variant.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/visibility.cljs b/frontend/src/app/main/ui/inspect/attributes/visibility.cljs index aa01245091..def01b78b4 100644 --- a/frontend/src/app/main/ui/inspect/attributes/visibility.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/visibility.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.visibility (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/visibility.scss b/frontend/src/app/main/ui/inspect/attributes/visibility.scss index 1942875929..de375cbc7f 100644 --- a/frontend/src/app/main/ui/inspect/attributes/visibility.scss +++ b/frontend/src/app/main/ui/inspect/attributes/visibility.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/code.cljs b/frontend/src/app/main/ui/inspect/code.cljs index 57f6ea10b8..78b748a064 100644 --- a/frontend/src/app/main/ui/inspect/code.cljs +++ b/frontend/src/app/main/ui/inspect/code.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.code (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/code.scss b/frontend/src/app/main/ui/inspect/code.scss index f2497e835d..58acf7b110 100644 --- a/frontend/src/app/main/ui/inspect/code.scss +++ b/frontend/src/app/main/ui/inspect/code.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/inspect/common/colors.cljs b/frontend/src/app/main/ui/inspect/common/colors.cljs index 8875c57325..083bd36aaf 100644 --- a/frontend/src/app/main/ui/inspect/common/colors.cljs +++ b/frontend/src/app/main/ui/inspect/common/colors.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.common.colors (:require diff --git a/frontend/src/app/main/ui/inspect/common/typography.cljs b/frontend/src/app/main/ui/inspect/common/typography.cljs index 85f25aa3d6..a9897162fc 100644 --- a/frontend/src/app/main/ui/inspect/common/typography.cljs +++ b/frontend/src/app/main/ui/inspect/common/typography.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.common.typography (:require diff --git a/frontend/src/app/main/ui/inspect/exports.cljs b/frontend/src/app/main/ui/inspect/exports.cljs index d431a5aeeb..f9a7636166 100644 --- a/frontend/src/app/main/ui/inspect/exports.cljs +++ b/frontend/src/app/main/ui/inspect/exports.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.exports (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/exports.scss b/frontend/src/app/main/ui/inspect/exports.scss index cec1335be5..1feb282b4c 100644 --- a/frontend/src/app/main/ui/inspect/exports.scss +++ b/frontend/src/app/main/ui/inspect/exports.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/inspect/left_sidebar.cljs b/frontend/src/app/main/ui/inspect/left_sidebar.cljs index 43c8a766ff..971a3d8062 100644 --- a/frontend/src/app/main/ui/inspect/left_sidebar.cljs +++ b/frontend/src/app/main/ui/inspect/left_sidebar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.left-sidebar (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/left_sidebar.scss b/frontend/src/app/main/ui/inspect/left_sidebar.scss index 3cd97d6345..280cca938e 100644 --- a/frontend/src/app/main/ui/inspect/left_sidebar.scss +++ b/frontend/src/app/main/ui/inspect/left_sidebar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/inspect/render.cljs b/frontend/src/app/main/ui/inspect/render.cljs index 25e223f9e5..751c1f4237 100644 --- a/frontend/src/app/main/ui/inspect/render.cljs +++ b/frontend/src/app/main/ui/inspect/render.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.render "The main container for a frame in inspect mode" diff --git a/frontend/src/app/main/ui/inspect/right_sidebar.cljs b/frontend/src/app/main/ui/inspect/right_sidebar.cljs index 3f0fdbe0b7..1216c02d8a 100644 --- a/frontend/src/app/main/ui/inspect/right_sidebar.cljs +++ b/frontend/src/app/main/ui/inspect/right_sidebar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.right-sidebar (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/right_sidebar.scss b/frontend/src/app/main/ui/inspect/right_sidebar.scss index 19fe574ec7..6d1ea82b4b 100644 --- a/frontend/src/app/main/ui/inspect/right_sidebar.scss +++ b/frontend/src/app/main/ui/inspect/right_sidebar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/selection_feedback.cljs b/frontend/src/app/main/ui/inspect/selection_feedback.cljs index 3679be0283..8be253b3ca 100644 --- a/frontend/src/app/main/ui/inspect/selection_feedback.cljs +++ b/frontend/src/app/main/ui/inspect/selection_feedback.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.selection-feedback (:require diff --git a/frontend/src/app/main/ui/inspect/styles.cljs b/frontend/src/app/main/ui/inspect/styles.cljs index df61a66778..1d1cb2cc1b 100644 --- a/frontend/src/app/main/ui/inspect/styles.cljs +++ b/frontend/src/app/main/ui/inspect/styles.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles.scss b/frontend/src/app/main/ui/inspect/styles.scss index b820c6843f..865542c3f2 100644 --- a/frontend/src/app/main/ui/inspect/styles.scss +++ b/frontend/src/app/main/ui/inspect/styles.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/styles/panels/blur.cljs b/frontend/src/app/main/ui/inspect/styles/panels/blur.cljs index 6f089cf256..98a4e6e77f 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/blur.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/blur.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.blur (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/fill.cljs b/frontend/src/app/main/ui/inspect/styles/panels/fill.cljs index 8ee04a012f..793c347964 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/fill.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/fill.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.fill (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/geometry.cljs b/frontend/src/app/main/ui/inspect/styles/panels/geometry.cljs index d008bf6f84..53d95fb00e 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/geometry.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/geometry.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.geometry (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/layout.cljs b/frontend/src/app/main/ui/inspect/styles/panels/layout.cljs index 0dd36cde3a..b75b31696f 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/layout.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/layout.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.layout (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/layout_element.cljs b/frontend/src/app/main/ui/inspect/styles/panels/layout_element.cljs index a3f5717dfc..1a941c75b9 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/layout_element.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/layout_element.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.layout-element (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/shadow.cljs b/frontend/src/app/main/ui/inspect/styles/panels/shadow.cljs index 59c006ebdd..b9693bbc4d 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/shadow.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/shadow.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.shadow (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/stroke.cljs b/frontend/src/app/main/ui/inspect/styles/panels/stroke.cljs index 2398eaede9..53ac6d5b8c 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/stroke.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/stroke.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.stroke (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/svg.cljs b/frontend/src/app/main/ui/inspect/styles/panels/svg.cljs index 1cc72bbbc7..8e9f89e3c1 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/svg.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/svg.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.svg (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/text.cljs b/frontend/src/app/main/ui/inspect/styles/panels/text.cljs index 23073d0c9c..f68c764913 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/text.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.text (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/text.scss b/frontend/src/app/main/ui/inspect/styles/panels/text.scss index 63dbe27892..06d16a0e47 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/text.scss +++ b/frontend/src/app/main/ui/inspect/styles/panels/text.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/styles/panels/tokens_panel.cljs b/frontend/src/app/main/ui/inspect/styles/panels/tokens_panel.cljs index 2dbbb3f97e..2a86d196ca 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/tokens_panel.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/tokens_panel.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.tokens-panel (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/tokens_panel.scss b/frontend/src/app/main/ui/inspect/styles/panels/tokens_panel.scss index 93e7790729..facf963877 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/tokens_panel.scss +++ b/frontend/src/app/main/ui/inspect/styles/panels/tokens_panel.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .token-theme, .token-sets { diff --git a/frontend/src/app/main/ui/inspect/styles/panels/variants_panel.cljs b/frontend/src/app/main/ui/inspect/styles/panels/variants_panel.cljs index 5d5a852b0f..aef2847adf 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/variants_panel.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/variants_panel.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.variants-panel (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/visibility.cljs b/frontend/src/app/main/ui/inspect/styles/panels/visibility.cljs index cdeee9a7d1..edebf000e3 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/visibility.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/visibility.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.visibility (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.cljs b/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.cljs index e6cb18f37a..1b5e92158c 100644 --- a/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.cljs +++ b/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.property-detail-copiable (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.scss b/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.scss index f7fccb5580..79129aca67 100644 --- a/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.scss +++ b/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.cljs b/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.cljs index 8420ad5873..4acd771ca1 100644 --- a/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.cljs +++ b/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.rows.color-properties-row (:require-macros [app.main.style :as stl]) (:require diff --git a/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.scss b/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.scss index 9469ca51d8..74a7ec79d9 100644 --- a/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.scss +++ b/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/styles/rows/properties_row.cljs b/frontend/src/app/main/ui/inspect/styles/rows/properties_row.cljs index 3c08dd55f3..ca8b02e629 100644 --- a/frontend/src/app/main/ui/inspect/styles/rows/properties_row.cljs +++ b/frontend/src/app/main/ui/inspect/styles/rows/properties_row.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.rows.properties-row (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/rows/properties_row.scss b/frontend/src/app/main/ui/inspect/styles/rows/properties_row.scss index 30df727a10..3b55fdd065 100644 --- a/frontend/src/app/main/ui/inspect/styles/rows/properties_row.scss +++ b/frontend/src/app/main/ui/inspect/styles/rows/properties_row.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/styles/style_box.cljs b/frontend/src/app/main/ui/inspect/styles/style_box.cljs index c106197fa3..0ac17b5411 100644 --- a/frontend/src/app/main/ui/inspect/styles/style_box.cljs +++ b/frontend/src/app/main/ui/inspect/styles/style_box.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.style-box (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/style_box.scss b/frontend/src/app/main/ui/inspect/styles/style_box.scss index 3ae3ba14bd..102cb312d2 100644 --- a/frontend/src/app/main/ui/inspect/styles/style_box.scss +++ b/frontend/src/app/main/ui/inspect/styles/style_box.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; diff --git a/frontend/src/app/main/ui/measurements.cljs b/frontend/src/app/main/ui/measurements.cljs index b775e7d1bb..9ba9226c81 100644 --- a/frontend/src/app/main/ui/measurements.cljs +++ b/frontend/src/app/main/ui/measurements.cljs @@ -2,18 +2,20 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.measurements (:require-macros [app.main.style :as stl]) (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] [app.common.geom.shapes :as gsh] [app.common.math :as mth] [app.common.types.component :as ctk] + [app.common.types.path :as path] [app.common.uuid :as uuid] [app.main.constants :as mconst] [app.main.ui.formats :as fmt] @@ -214,6 +216,11 @@ single-shape (and (= (count shapes) 1) (first shapes)) + ;; Straight paths use endpoint controls instead of a size badge. + single-line? (and single-shape + (cfh/path-shape? single-shape) + (path/single-line? (dm/get-prop single-shape :content))) + component-color? (if single-shape (ctk/instance-head? single-shape) (every? ctk/instance-head? shapes)) @@ -245,64 +252,65 @@ text-width (* (count text) badge-char-width) badge-width (+ text-width (* 2 badge-padding-x))] - (if has-rotation? - (let [edge (get-edge-for-badge rotation) - points (dm/get-prop single-shape :points) + (when-not ^boolean single-line? + (if has-rotation? + (let [edge (get-edge-for-badge rotation) + points (dm/get-prop single-shape :points) - [ep1 ep2] (get-edge-points points edge) + [ep1 ep2] (get-edge-points points edge) - mid-point (gpt/lerp ep1 ep2 0.5) - normal (gpt/normal-right (gpt/subtract ep2 ep1)) + mid-point (gpt/lerp ep1 ep2 0.5) + normal (gpt/normal-right (gpt/subtract ep2 ep1)) - rot-offset (case edge - :bottom 0 - :right 270 - :top 180 - :left 90) - badge-rot (+ rotation rot-offset) - offset (+ badge-gap (/ badge-height 2)) + rot-offset (case edge + :bottom 0 + :right 270 + :top 180 + :left 90) + badge-rot (+ rotation rot-offset) + offset (+ badge-gap (/ badge-height 2)) - badge-x (- (/ badge-width 2)) - badge-y (- (/ badge-height 2)) - badge-cx (+ (:x mid-point) (* (:x normal) offset)) - badge-cy (+ (:y mid-point) (* (:y normal) offset))] + badge-x (- (/ badge-width 2)) + badge-y (- (/ badge-height 2)) + badge-cx (+ (:x mid-point) (* (:x normal) offset)) + badge-cy (+ (:y mid-point) (* (:y normal) offset))] - [:g.selection-size-badge {:pointer-events "none" - :transform (dm/str "translate(" badge-cx "," badge-cy ") rotate(" badge-rot ")")} - [:rect {:x badge-x - :y badge-y - :width badge-width - :height badge-height - :rx badge-radius - :ry badge-radius - :style {:fill badge-bg-color}}] - [:text {:class (stl/css :badge-text) - :x 0 - :y 0 - :text-anchor "middle" - :dominant-baseline "middle"} - text]]) + [:g.selection-size-badge {:pointer-events "none" + :transform (dm/str "translate(" badge-cx "," badge-cy ") rotate(" badge-rot ")")} + [:rect {:x badge-x + :y badge-y + :width badge-width + :height badge-height + :rx badge-radius + :ry badge-radius + :style {:fill badge-bg-color}}] + [:text {:class (stl/css :badge-text) + :x 0 + :y 0 + :text-anchor "middle" + :dominant-baseline "middle"} + text]]) - (let [badge-x (- (/ badge-width 2)) - badge-y (- (/ badge-height 2)) - badge-cx (+ (:x selrect) (/ (:width selrect) 2)) - badge-cy (+ (:y selrect) (:height selrect) badge-gap (/ badge-height 2))] + (let [badge-x (- (/ badge-width 2)) + badge-y (- (/ badge-height 2)) + badge-cx (+ (:x selrect) (/ (:width selrect) 2)) + badge-cy (+ (:y selrect) (:height selrect) badge-gap (/ badge-height 2))] - [:g.selection-size-badge {:pointer-events "none" - :transform (dm/str "translate(" badge-cx "," badge-cy ")")} - [:rect {:x badge-x - :y badge-y - :width badge-width - :height badge-height - :rx badge-radius - :ry badge-radius - :style {:fill badge-bg-color}}] - [:text {:class (stl/css :badge-text) - :x 0 - :y 0 - :text-anchor "middle" - :dominant-baseline "middle"} - text]])))) + [:g.selection-size-badge {:pointer-events "none" + :transform (dm/str "translate(" badge-cx "," badge-cy ")")} + [:rect {:x badge-x + :y badge-y + :width badge-width + :height badge-height + :rx badge-radius + :ry badge-radius + :style {:fill badge-bg-color}}] + [:text {:class (stl/css :badge-text) + :x 0 + :y 0 + :text-anchor "middle" + :dominant-baseline "middle"} + text]]))))) (mf/defc distance-display* [{:keys [from to zoom bounds]}] (let [fixed-x (if (gsh/fully-contained? from to) @@ -384,4 +392,3 @@ [:> selection-rect* {:type :hover :selrect hover-selrect :zoom zoom}] [:> size-display* {:selrect hover-selrect :zoom zoom}] [:> distance-display* {:from hover-selrect :to selected-selrect :zoom zoom :bounds bounds-selrect}]])]))) - diff --git a/frontend/src/app/main/ui/measurements.scss b/frontend/src/app/main/ui/measurements.scss index 4babfe43dc..09c70ca53b 100644 --- a/frontend/src/app/main/ui/measurements.scss +++ b/frontend/src/app/main/ui/measurements.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs index 75ecfdbe28..dcba96e327 100644 --- a/frontend/src/app/main/ui/modal.cljs +++ b/frontend/src/app/main/ui/modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.modal (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/modal.scss b/frontend/src/app/main/ui/modal.scss index be7675a9a1..f690745fdb 100644 --- a/frontend/src/app/main/ui/modal.scss +++ b/frontend/src/app/main/ui/modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/nitrate/entry.cljs b/frontend/src/app/main/ui/nitrate/entry.cljs index 4bcadf3216..c1defa2cb0 100644 --- a/frontend/src/app/main/ui/nitrate/entry.cljs +++ b/frontend/src/app/main/ui/nitrate/entry.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.nitrate.entry (:require diff --git a/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs b/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs index 8489cdec4a..abe9db8bfc 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.nitrate.nitrate-activation-success-modal (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.scss b/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.scss index 5f1e8dd483..215d01a2b4 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.scss +++ b/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs index 7ad802a2b8..5d8f51a51f 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.nitrate.nitrate-code-activation-modal (:require-macros [app.main.style :as stl]) @@ -53,10 +53,10 @@ (modal/show {:type :nitrate-activation-success}) (dprof/refresh-profile)))) (fn [error] - ;; TODO: "Already used" is not yet detectable (CC upserts on reuse). (let [code (-> error ex-data :code)] (reset! error* (case code :expired-activation-code (tr "nitrate.activation-code.expired-error") + :used-activation-code (tr "nitrate.activation-code.used-error") (tr "nitrate.activation-code.invalid-error"))))))))))) on-key-down @@ -111,11 +111,16 @@ :value (tr "nitrate.code-activation.submit") :on-click on-accept}]] [:div {:class (stl/css :footer-text)} - (tr "nitrate.code-activation.footer-before") - [:a {:class (stl/css :link) - :on-click on-download-request-click} - (tr "nitrate.code-activation.footer-link")] - (tr "nitrate.code-activation.footer-after") " " - [:a {:class (stl/css :link) - :href "mailto:sales@nitrate.com"} - "sales@nitrate.com"]]]]])) + [:div {:class (stl/css :code-label)} (tr "nitrate.code-activation.footer-title")] + [:div + + [:a {:class (stl/css :link) + :on-click on-download-request-click} + (tr "nitrate.code-activation.footer-download")]] + [:div + (tr "nitrate.code-activation.footer-after") " " + [:a {:class (stl/css :link) + :href "mailto:sales@penpot.app"} + "sales@penpot.app"] + " " + (tr "nitrate.code-activation.footer-before")]]]]])) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.scss b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.scss index a26030f410..0cdeebf33e 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.scss +++ b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs index 3ad31a1977..93e3a61eda 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.nitrate.nitrate-form (:require-macros [app.main.style :as stl]) @@ -102,9 +102,9 @@ (tr "nitrate.form.cancel-anytime")]]] [:p {:class (stl/css :modal-text-medium)} - (tr "nitrate.form.subscribe-with-code") " " [:a {:class (stl/css :link) - :on-click on-activate-click} - (tr "nitrate.form.enter-code")]] + [:a {:class (stl/css :link) + :on-click on-activate-click} + (tr "nitrate.form.subscribe-with-code")]] [:p {:class (stl/css :modal-text-medium)} [:a {:class (stl/css :link) @@ -121,9 +121,7 @@ [:a {:class (stl/css :link) :href "mailto:sales@penpot.app"} "sales@penpot.app"]] [:div {:class (stl/css :activation-code)} - [:p {:class (stl/css :modal-text-large)} - (tr "nitrate.form.subscribe-with-code")] [:p {:class (stl/css :modal-text-large)} [:a {:class (stl/css :link) :on-click on-activate-click} - (tr "nitrate.form.enter-code")]]]])]]]])) + (tr "nitrate.form.subscribe-with-code")]]]])]]]])) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.scss b/frontend/src/app/main/ui/nitrate/nitrate_form.scss index 5de76305e5..5ca982d0f6 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_form.scss +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/notifications.cljs b/frontend/src/app/main/ui/notifications.cljs index 47c038f835..b6b3e01f64 100644 --- a/frontend/src/app/main/ui/notifications.cljs +++ b/frontend/src/app/main/ui/notifications.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.notifications (:require @@ -35,6 +35,7 @@ [:> toast* {:level (or (:level notification) :info) :type (:type notification) + :is-html (boolean (:is-html notification)) :detail (:detail notification) :on-close on-close} content] @@ -57,5 +58,6 @@ [:> toast* {:level (or (:level notification) :info) :type (:type notification) + :is-html (boolean (:is-html notification)) :detail (:detail notification) :on-close on-close} content])))) diff --git a/frontend/src/app/main/ui/notifications/badge.cljs b/frontend/src/app/main/ui/notifications/badge.cljs index 33f33c8e8c..36f428e044 100644 --- a/frontend/src/app/main/ui/notifications/badge.cljs +++ b/frontend/src/app/main/ui/notifications/badge.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.notifications.badge (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/notifications/badge.scss b/frontend/src/app/main/ui/notifications/badge.scss index 6741eef522..6e003ad0dd 100644 --- a/frontend/src/app/main/ui/notifications/badge.scss +++ b/frontend/src/app/main/ui/notifications/badge.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/notifications/context_notification.cljs b/frontend/src/app/main/ui/notifications/context_notification.cljs index 2c07d5218d..923a4958a3 100644 --- a/frontend/src/app/main/ui/notifications/context_notification.cljs +++ b/frontend/src/app/main/ui/notifications/context_notification.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.notifications.context-notification (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/notifications/context_notification.scss b/frontend/src/app/main/ui/notifications/context_notification.scss index 7f6f5079ad..e4cb80b794 100644 --- a/frontend/src/app/main/ui/notifications/context_notification.scss +++ b/frontend/src/app/main/ui/notifications/context_notification.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/notifications/inline_notification.cljs b/frontend/src/app/main/ui/notifications/inline_notification.cljs index 2934b71236..c4fa5b70e8 100644 --- a/frontend/src/app/main/ui/notifications/inline_notification.cljs +++ b/frontend/src/app/main/ui/notifications/inline_notification.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.notifications.inline-notification (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/notifications/inline_notification.scss b/frontend/src/app/main/ui/notifications/inline_notification.scss index 65f912abeb..21c73edc04 100644 --- a/frontend/src/app/main/ui/notifications/inline_notification.scss +++ b/frontend/src/app/main/ui/notifications/inline_notification.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/onboarding/questions.cljs b/frontend/src/app/main/ui/onboarding/questions.cljs index f281503bc1..a3c19b32fc 100644 --- a/frontend/src/app/main/ui/onboarding/questions.cljs +++ b/frontend/src/app/main/ui/onboarding/questions.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.onboarding.questions "External form for onboarding questions." diff --git a/frontend/src/app/main/ui/onboarding/questions.scss b/frontend/src/app/main/ui/onboarding/questions.scss index d9363415f7..9a1adaccc6 100644 --- a/frontend/src/app/main/ui/onboarding/questions.scss +++ b/frontend/src/app/main/ui/onboarding/questions.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/onboarding/team_choice.cljs b/frontend/src/app/main/ui/onboarding/team_choice.cljs index fbd57b9826..d91f9fa4ea 100644 --- a/frontend/src/app/main/ui/onboarding/team_choice.cljs +++ b/frontend/src/app/main/ui/onboarding/team_choice.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.onboarding.team-choice (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/onboarding/team_choice.scss b/frontend/src/app/main/ui/onboarding/team_choice.scss index a86e316e21..92cdcfb6e5 100644 --- a/frontend/src/app/main/ui/onboarding/team_choice.scss +++ b/frontend/src/app/main/ui/onboarding/team_choice.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/onboarding/templates.cljs b/frontend/src/app/main/ui/onboarding/templates.cljs index 3fccd1dc60..5ef15a2546 100644 --- a/frontend/src/app/main/ui/onboarding/templates.cljs +++ b/frontend/src/app/main/ui/onboarding/templates.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.onboarding.templates (:require diff --git a/frontend/src/app/main/ui/releases.cljs b/frontend/src/app/main/ui/releases.cljs index eb00d11eed..d7ee44c17a 100644 --- a/frontend/src/app/main/ui/releases.cljs +++ b/frontend/src/app/main/ui/releases.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases (:require diff --git a/frontend/src/app/main/ui/releases/common.cljs b/frontend/src/app/main/ui/releases/common.cljs index 239cb129fc..5c57968702 100644 --- a/frontend/src/app/main/ui/releases/common.cljs +++ b/frontend/src/app/main/ui/releases/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.common (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/common.scss b/frontend/src/app/main/ui/releases/common.scss index 84b9f80278..9aa02e3d36 100644 --- a/frontend/src/app/main/ui/releases/common.scss +++ b/frontend/src/app/main/ui/releases/common.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v1_10.cljs b/frontend/src/app/main/ui/releases/v1_10.cljs index 40ce722ef4..e42930f4b4 100644 --- a/frontend/src/app/main/ui/releases/v1_10.cljs +++ b/frontend/src/app/main/ui/releases/v1_10.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-10 (:require diff --git a/frontend/src/app/main/ui/releases/v1_11.cljs b/frontend/src/app/main/ui/releases/v1_11.cljs index 88061340b8..11236c2360 100644 --- a/frontend/src/app/main/ui/releases/v1_11.cljs +++ b/frontend/src/app/main/ui/releases/v1_11.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-11 (:require diff --git a/frontend/src/app/main/ui/releases/v1_12.cljs b/frontend/src/app/main/ui/releases/v1_12.cljs index 8cfcb9c84b..0ef0fd7fbc 100644 --- a/frontend/src/app/main/ui/releases/v1_12.cljs +++ b/frontend/src/app/main/ui/releases/v1_12.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-12 (:require diff --git a/frontend/src/app/main/ui/releases/v1_13.cljs b/frontend/src/app/main/ui/releases/v1_13.cljs index a921d6d163..6edb6562fe 100644 --- a/frontend/src/app/main/ui/releases/v1_13.cljs +++ b/frontend/src/app/main/ui/releases/v1_13.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-13 (:require diff --git a/frontend/src/app/main/ui/releases/v1_14.cljs b/frontend/src/app/main/ui/releases/v1_14.cljs index fb59584875..eb103c34b6 100644 --- a/frontend/src/app/main/ui/releases/v1_14.cljs +++ b/frontend/src/app/main/ui/releases/v1_14.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-14 (:require diff --git a/frontend/src/app/main/ui/releases/v1_15.cljs b/frontend/src/app/main/ui/releases/v1_15.cljs index 8747d9f84c..5b62d0b6e2 100644 --- a/frontend/src/app/main/ui/releases/v1_15.cljs +++ b/frontend/src/app/main/ui/releases/v1_15.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-15 (:require diff --git a/frontend/src/app/main/ui/releases/v1_16.cljs b/frontend/src/app/main/ui/releases/v1_16.cljs index fcbf2a5d28..0b9adc096e 100644 --- a/frontend/src/app/main/ui/releases/v1_16.cljs +++ b/frontend/src/app/main/ui/releases/v1_16.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-16 (:require diff --git a/frontend/src/app/main/ui/releases/v1_17.cljs b/frontend/src/app/main/ui/releases/v1_17.cljs index 961e4f31f7..a25cbecbf0 100644 --- a/frontend/src/app/main/ui/releases/v1_17.cljs +++ b/frontend/src/app/main/ui/releases/v1_17.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-17 (:require diff --git a/frontend/src/app/main/ui/releases/v1_18.cljs b/frontend/src/app/main/ui/releases/v1_18.cljs index 970b3dd20d..350f69889a 100644 --- a/frontend/src/app/main/ui/releases/v1_18.cljs +++ b/frontend/src/app/main/ui/releases/v1_18.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-18 (:require diff --git a/frontend/src/app/main/ui/releases/v1_19.cljs b/frontend/src/app/main/ui/releases/v1_19.cljs index e17ab646a3..706bc543ec 100644 --- a/frontend/src/app/main/ui/releases/v1_19.cljs +++ b/frontend/src/app/main/ui/releases/v1_19.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-19 (:require diff --git a/frontend/src/app/main/ui/releases/v1_4.cljs b/frontend/src/app/main/ui/releases/v1_4.cljs index e914e31161..6cf2ebe489 100644 --- a/frontend/src/app/main/ui/releases/v1_4.cljs +++ b/frontend/src/app/main/ui/releases/v1_4.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-4 (:require diff --git a/frontend/src/app/main/ui/releases/v1_5.cljs b/frontend/src/app/main/ui/releases/v1_5.cljs index 3470033bf9..f7ccaef100 100644 --- a/frontend/src/app/main/ui/releases/v1_5.cljs +++ b/frontend/src/app/main/ui/releases/v1_5.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-5 (:require diff --git a/frontend/src/app/main/ui/releases/v1_6.cljs b/frontend/src/app/main/ui/releases/v1_6.cljs index 46ab5c54f7..5cedab7a02 100644 --- a/frontend/src/app/main/ui/releases/v1_6.cljs +++ b/frontend/src/app/main/ui/releases/v1_6.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-6 (:require diff --git a/frontend/src/app/main/ui/releases/v1_7.cljs b/frontend/src/app/main/ui/releases/v1_7.cljs index db8724fb6a..3f1b83f11d 100644 --- a/frontend/src/app/main/ui/releases/v1_7.cljs +++ b/frontend/src/app/main/ui/releases/v1_7.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-7 (:require diff --git a/frontend/src/app/main/ui/releases/v1_8.cljs b/frontend/src/app/main/ui/releases/v1_8.cljs index 5e097fa1a5..a46f475121 100644 --- a/frontend/src/app/main/ui/releases/v1_8.cljs +++ b/frontend/src/app/main/ui/releases/v1_8.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-8 (:require diff --git a/frontend/src/app/main/ui/releases/v1_9.cljs b/frontend/src/app/main/ui/releases/v1_9.cljs index 2d43e309fb..01904afeb3 100644 --- a/frontend/src/app/main/ui/releases/v1_9.cljs +++ b/frontend/src/app/main/ui/releases/v1_9.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-9 (:require diff --git a/frontend/src/app/main/ui/releases/v2_0.cljs b/frontend/src/app/main/ui/releases/v2_0.cljs index 1bb988d0bf..a26b9001f4 100644 --- a/frontend/src/app/main/ui/releases/v2_0.cljs +++ b/frontend/src/app/main/ui/releases/v2_0.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-0 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_0.scss b/frontend/src/app/main/ui/releases/v2_0.scss index c759917f4f..55fd39b307 100644 --- a/frontend/src/app/main/ui/releases/v2_0.scss +++ b/frontend/src/app/main/ui/releases/v2_0.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_1.cljs b/frontend/src/app/main/ui/releases/v2_1.cljs index 98c205d8ad..8e848e43e5 100644 --- a/frontend/src/app/main/ui/releases/v2_1.cljs +++ b/frontend/src/app/main/ui/releases/v2_1.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-1 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_1.scss b/frontend/src/app/main/ui/releases/v2_1.scss index 32416f9f9c..e20bb773b1 100644 --- a/frontend/src/app/main/ui/releases/v2_1.scss +++ b/frontend/src/app/main/ui/releases/v2_1.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_10.cljs b/frontend/src/app/main/ui/releases/v2_10.cljs index 0723a65286..b6b3d6b699 100644 --- a/frontend/src/app/main/ui/releases/v2_10.cljs +++ b/frontend/src/app/main/ui/releases/v2_10.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-10 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_10.scss b/frontend/src/app/main/ui/releases/v2_10.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_10.scss +++ b/frontend/src/app/main/ui/releases/v2_10.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_11.cljs b/frontend/src/app/main/ui/releases/v2_11.cljs index 7e2f32df16..508d63b043 100644 --- a/frontend/src/app/main/ui/releases/v2_11.cljs +++ b/frontend/src/app/main/ui/releases/v2_11.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-11 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_11.scss b/frontend/src/app/main/ui/releases/v2_11.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_11.scss +++ b/frontend/src/app/main/ui/releases/v2_11.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_12.cljs b/frontend/src/app/main/ui/releases/v2_12.cljs index 0f709c553c..2f540eff71 100644 --- a/frontend/src/app/main/ui/releases/v2_12.cljs +++ b/frontend/src/app/main/ui/releases/v2_12.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-12 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_12.scss b/frontend/src/app/main/ui/releases/v2_12.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_12.scss +++ b/frontend/src/app/main/ui/releases/v2_12.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_13.cljs b/frontend/src/app/main/ui/releases/v2_13.cljs index 569279cabf..ba3e7f04ce 100644 --- a/frontend/src/app/main/ui/releases/v2_13.cljs +++ b/frontend/src/app/main/ui/releases/v2_13.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-13 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_13.scss b/frontend/src/app/main/ui/releases/v2_13.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_13.scss +++ b/frontend/src/app/main/ui/releases/v2_13.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_14.cljs b/frontend/src/app/main/ui/releases/v2_14.cljs index 925d6ebaa6..22382554ae 100644 --- a/frontend/src/app/main/ui/releases/v2_14.cljs +++ b/frontend/src/app/main/ui/releases/v2_14.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-14 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_14.scss b/frontend/src/app/main/ui/releases/v2_14.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_14.scss +++ b/frontend/src/app/main/ui/releases/v2_14.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_15.cljs b/frontend/src/app/main/ui/releases/v2_15.cljs index f5032dd573..6c6c130af1 100644 --- a/frontend/src/app/main/ui/releases/v2_15.cljs +++ b/frontend/src/app/main/ui/releases/v2_15.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-15 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_15.scss b/frontend/src/app/main/ui/releases/v2_15.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_15.scss +++ b/frontend/src/app/main/ui/releases/v2_15.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_16.cljs b/frontend/src/app/main/ui/releases/v2_16.cljs index 833beaf100..c6a4e5d953 100644 --- a/frontend/src/app/main/ui/releases/v2_16.cljs +++ b/frontend/src/app/main/ui/releases/v2_16.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-16 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_16.scss b/frontend/src/app/main/ui/releases/v2_16.scss index 40c8f5316f..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_16.scss +++ b/frontend/src/app/main/ui/releases/v2_16.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_17.cljs b/frontend/src/app/main/ui/releases/v2_17.cljs index 246c19174d..78390fd2d7 100644 --- a/frontend/src/app/main/ui/releases/v2_17.cljs +++ b/frontend/src/app/main/ui/releases/v2_17.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-17 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_17.scss b/frontend/src/app/main/ui/releases/v2_17.scss index 40c8f5316f..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_17.scss +++ b/frontend/src/app/main/ui/releases/v2_17.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_2.cljs b/frontend/src/app/main/ui/releases/v2_2.cljs index fc99bf0392..a1eae34086 100644 --- a/frontend/src/app/main/ui/releases/v2_2.cljs +++ b/frontend/src/app/main/ui/releases/v2_2.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-2 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_2.scss b/frontend/src/app/main/ui/releases/v2_2.scss index 3da79041af..52a7da75f0 100644 --- a/frontend/src/app/main/ui/releases/v2_2.scss +++ b/frontend/src/app/main/ui/releases/v2_2.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_3.cljs b/frontend/src/app/main/ui/releases/v2_3.cljs index 6771455a97..a6afd823f1 100644 --- a/frontend/src/app/main/ui/releases/v2_3.cljs +++ b/frontend/src/app/main/ui/releases/v2_3.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-3 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_3.scss b/frontend/src/app/main/ui/releases/v2_3.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_3.scss +++ b/frontend/src/app/main/ui/releases/v2_3.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_4.cljs b/frontend/src/app/main/ui/releases/v2_4.cljs index cee512c6c0..40c6e6998b 100644 --- a/frontend/src/app/main/ui/releases/v2_4.cljs +++ b/frontend/src/app/main/ui/releases/v2_4.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-4 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_4.scss b/frontend/src/app/main/ui/releases/v2_4.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_4.scss +++ b/frontend/src/app/main/ui/releases/v2_4.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_5.cljs b/frontend/src/app/main/ui/releases/v2_5.cljs index e195fc92c8..76c87de90b 100644 --- a/frontend/src/app/main/ui/releases/v2_5.cljs +++ b/frontend/src/app/main/ui/releases/v2_5.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-5 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_5.scss b/frontend/src/app/main/ui/releases/v2_5.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_5.scss +++ b/frontend/src/app/main/ui/releases/v2_5.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_6.cljs b/frontend/src/app/main/ui/releases/v2_6.cljs index bbb456f6ac..17ce515507 100644 --- a/frontend/src/app/main/ui/releases/v2_6.cljs +++ b/frontend/src/app/main/ui/releases/v2_6.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-6 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_6.scss b/frontend/src/app/main/ui/releases/v2_6.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_6.scss +++ b/frontend/src/app/main/ui/releases/v2_6.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_7.cljs b/frontend/src/app/main/ui/releases/v2_7.cljs index 056744c572..7e845db312 100644 --- a/frontend/src/app/main/ui/releases/v2_7.cljs +++ b/frontend/src/app/main/ui/releases/v2_7.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-7 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_7.scss b/frontend/src/app/main/ui/releases/v2_7.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_7.scss +++ b/frontend/src/app/main/ui/releases/v2_7.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_8.cljs b/frontend/src/app/main/ui/releases/v2_8.cljs index dc343efe0c..b701b72ec0 100644 --- a/frontend/src/app/main/ui/releases/v2_8.cljs +++ b/frontend/src/app/main/ui/releases/v2_8.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-8 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_8.scss b/frontend/src/app/main/ui/releases/v2_8.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_8.scss +++ b/frontend/src/app/main/ui/releases/v2_8.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_9.cljs b/frontend/src/app/main/ui/releases/v2_9.cljs index a4f26fd45d..d9b9e4e3b8 100644 --- a/frontend/src/app/main/ui/releases/v2_9.cljs +++ b/frontend/src/app/main/ui/releases/v2_9.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-9 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_9.scss b/frontend/src/app/main/ui/releases/v2_9.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_9.scss +++ b/frontend/src/app/main/ui/releases/v2_9.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index 52d7554e1e..6502958f1e 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -2,11 +2,12 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.routes (:require [app.common.data.macros :as dm] + [app.common.time :as ct] [app.common.uri :as u] [app.common.uuid :as uuid] [app.config :as cf] @@ -21,6 +22,12 @@ [cuerdas.core :as str] [potok.v2.core :as ptk])) +(def ^:private sso-authorization-max-age-ms + (* 5 60 1000)) + +(defonce ^:private sso-authorization-cache + (atom {})) + (def routes [["/auth" ["/login" :auth-login] @@ -102,26 +109,68 @@ "Authorization filter for dashboard and workspace routes. Checks if the team being navigated to has an organization with SSO active. If so, calls :check-nitrate-sso and either proceeds with navigation - or redirects to the SSO provider URL." + or redirects to the SSO provider URL. Successful checks are cached for five + minutes per profile and team; redirect results are never cached." [match send-event-info? url] - (let [route-name (name (get-in match [:data :name])) - relevant? (and (contains? cf/flags :admin-console) - (or (str/starts-with? route-name "dashboard") - (str/starts-with? route-name "workspace"))) - team-id-str (when relevant? - (or (get-in match [:query-params :team-id]) - (get-in match [:params :path :team-id]))) - team-id (some-> team-id-str uuid/parse*)] - (if (some? team-id) + (let [route-name (name (get-in match [:data :name])) + relevant? (and (contains? cf/flags :admin-console) + (or (str/starts-with? route-name "dashboard") + (str/starts-with? route-name "workspace"))) + team-id-str (when relevant? + (or (get-in match [:query-params :team-id]) + (get-in match [:params :path :team-id]))) + team-id (some-> team-id-str uuid/parse*) + profile-id (get-in @st/state [:profile :id]) + cache-key [profile-id team-id] + authorized-at (get @sso-authorization-cache cache-key) + cache-valid? (and (some? authorized-at) + (< (ct/diff-ms authorized-at (ct/now)) + sso-authorization-max-age-ms)) + navigate #(st/emit! (rt/navigated match send-event-info?))] + (cond + (nil? team-id) + (navigate) + + cache-valid? + (navigate) + + :else (->> (rp/cmd! :check-nitrate-sso {:team-id team-id :url url}) (rx/subs! (fn [{:keys [authorized redirect-uri]}] (if authorized - (st/emit! (rt/navigated match send-event-info?)) - (when redirect-uri (st/emit! (rt/nav-raw :uri (str redirect-uri)))))) + (do + (swap! sso-authorization-cache assoc cache-key (ct/now)) + (navigate)) + (when redirect-uri + (st/emit! (rt/nav-raw :uri (str redirect-uri)))))) (fn [cause] - (errors/on-error cause)))) - (st/emit! (rt/navigated match send-event-info?))))) + (errors/on-error cause))))))) + +(defn- handle-sso-error-and-navigate + "Check if the current route has an SSO error marker. If so, assign an + exception with type :sso-error and organization-id/name from query params, + and deliberately do NOT proceed with normal navigation: emitting + `rt/navigated` would clear the exception that was just assigned. + Otherwise, delegate to `check-sso-and-navigate`." + [match send-event-info? url] + (let [route-name (name (get-in match [:data :name])) + sso-error? (some? (get-in match [:query-params :sso-error])) + organization-id (some-> (get-in match [:query-params :organization-id]) uuid/parse*) + organization-name (some-> (get-in match [:query-params :organization-name]) str/trim) + team-id-str (or (get-in match [:query-params :team-id]) + (get-in match [:params :path :team-id])) ;; Fallback: team-id may be in path params for workspace routes + team-id (some-> team-id-str uuid/parse*) + is-workspace? (str/starts-with? route-name "workspace") + is-dashboard? (str/starts-with? route-name "dashboard")] + (if sso-error? + (st/emit! (rt/assign-exception {:type :sso-error + :organization-id organization-id + :organization-name organization-name + :team-id team-id + :is-workspace is-workspace? + :is-dashboard is-dashboard?})) + (check-sso-and-navigate match send-event-info? url)))) (defn on-navigate [router path send-event-info?] @@ -138,7 +187,7 @@ (st/emit! (rt/assign-exception {:type :not-found})) (some? match) - (check-sso-and-navigate match send-event-info? (rt/get-current-href)) + (handle-sso-error-and-navigate match send-event-info? (rt/get-current-href)) :else ;; We just recheck with an additional profile request; this diff --git a/frontend/src/app/main/ui/settings.cljs b/frontend/src/app/main/ui/settings.cljs index 3a4b98aed4..97043fae0a 100644 --- a/frontend/src/app/main/ui/settings.cljs +++ b/frontend/src/app/main/ui/settings.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/settings.scss b/frontend/src/app/main/ui/settings.scss index b553370331..5898dbac3b 100644 --- a/frontend/src/app/main/ui/settings.scss +++ b/frontend/src/app/main/ui/settings.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "common/refactor/common-dashboard"; @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/settings/change_email.cljs b/frontend/src/app/main/ui/settings/change_email.cljs index deec07de43..98e15e2c58 100644 --- a/frontend/src/app/main/ui/settings/change_email.cljs +++ b/frontend/src/app/main/ui/settings/change_email.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.change-email (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/settings/change_email.scss b/frontend/src/app/main/ui/settings/change_email.scss index 51d505e6e9..4795cf6ac6 100644 --- a/frontend/src/app/main/ui/settings/change_email.scss +++ b/frontend/src/app/main/ui/settings/change_email.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/settings/delete_account.cljs b/frontend/src/app/main/ui/settings/delete_account.cljs index b6d0a0e4c8..65d67d0e6e 100644 --- a/frontend/src/app/main/ui/settings/delete_account.cljs +++ b/frontend/src/app/main/ui/settings/delete_account.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.delete-account (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/settings/delete_account.scss b/frontend/src/app/main/ui/settings/delete_account.scss index ff4a0849e9..4d0c1efafc 100644 --- a/frontend/src/app/main/ui/settings/delete_account.scss +++ b/frontend/src/app/main/ui/settings/delete_account.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/settings/feedback.cljs b/frontend/src/app/main/ui/settings/feedback.cljs index ee7fd553e9..b5a1a88373 100644 --- a/frontend/src/app/main/ui/settings/feedback.cljs +++ b/frontend/src/app/main/ui/settings/feedback.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.feedback "Feedback form." diff --git a/frontend/src/app/main/ui/settings/feedback.scss b/frontend/src/app/main/ui/settings/feedback.scss index 2a5c50b65d..aee1490e20 100644 --- a/frontend/src/app/main/ui/settings/feedback.scss +++ b/frontend/src/app/main/ui/settings/feedback.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "common/refactor/common-refactor" as *; @use "./profile"; diff --git a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs index bb0c8a81af..1afd0e768f 100644 --- a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs +++ b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.import-shortcuts-diff-modal (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss index 0d42b9d1a6..e810578008 100644 --- a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss +++ b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/settings/integrations.cljs b/frontend/src/app/main/ui/settings/integrations.cljs index d8c137561a..334ca32806 100644 --- a/frontend/src/app/main/ui/settings/integrations.cljs +++ b/frontend/src/app/main/ui/settings/integrations.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.integrations (:require-macros [app.main.style :as stl]) @@ -79,13 +79,19 @@ (mf/deps token-created) (fn [event] (dom/prevent-default event) - (clipboard/to-clipboard (:token token-created)) - (st/emit! (ntf/show {:level :info - :type :toast - :content (if is-mcp - (tr "integrations.notification.success.mcp-key-copied") - (tr "integrations.notification.success.token-copied")) - :timeout notification-timeout}))))] + (-> (clipboard/to-clipboard (:token token-created)) + (.then (fn [_] + (st/emit! (ntf/show {:level :info + :type :toast + :content (if is-mcp + (tr "integrations.notification.success.mcp-key-copied") + (tr "integrations.notification.success.token-copied")) + :timeout notification-timeout})))) + (.catch (fn [_] + (st/emit! (ntf/show {:level :error + :type :toast + :content (tr "errors.clipboard-api-unavailable") + :timeout notification-timeout})))))))] [:div {:class (stl/css :modal-form)} [:> text* {:as "h2" diff --git a/frontend/src/app/main/ui/settings/integrations.scss b/frontend/src/app/main/ui/settings/integrations.scss index 25e12a65b1..174cf8a73d 100644 --- a/frontend/src/app/main/ui/settings/integrations.scss +++ b/frontend/src/app/main/ui/settings/integrations.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/settings/notifications.cljs b/frontend/src/app/main/ui/settings/notifications.cljs index fdc8408006..6bf42e5849 100644 --- a/frontend/src/app/main/ui/settings/notifications.cljs +++ b/frontend/src/app/main/ui/settings/notifications.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.notifications (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/settings/notifications.scss b/frontend/src/app/main/ui/settings/notifications.scss index 333c49f1c8..93f1bd7edf 100644 --- a/frontend/src/app/main/ui/settings/notifications.scss +++ b/frontend/src/app/main/ui/settings/notifications.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "./profile" as *; diff --git a/frontend/src/app/main/ui/settings/options.cljs b/frontend/src/app/main/ui/settings/options.cljs index 23932ba133..83ecd648d0 100644 --- a/frontend/src/app/main/ui/settings/options.cljs +++ b/frontend/src/app/main/ui/settings/options.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.options (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/settings/options.scss b/frontend/src/app/main/ui/settings/options.scss index d5755e6062..cdae1e185b 100644 --- a/frontend/src/app/main/ui/settings/options.scss +++ b/frontend/src/app/main/ui/settings/options.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "./profile" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/settings/password.cljs b/frontend/src/app/main/ui/settings/password.cljs index cf3d60ed8d..434cd9de4e 100644 --- a/frontend/src/app/main/ui/settings/password.cljs +++ b/frontend/src/app/main/ui/settings/password.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.password (:require-macros [app.main.style :as stl]) @@ -28,6 +28,14 @@ (swap! form assoc-in [:extra-errors :password-1] {:message (tr "errors.email-as-password")}) + :weak-password + (let [details (:details data) + options (when (seq details) + (mapv tr details))] + (swap! form assoc-in [:extra-errors :password-1] + {:message (tr "errors.weak-password") + :options options})) + (let [msg (tr "generic.error")] (st/emit! (ntf/error msg)))))) diff --git a/frontend/src/app/main/ui/settings/password.scss b/frontend/src/app/main/ui/settings/password.scss index f1eaf13872..2ca11674b9 100644 --- a/frontend/src/app/main/ui/settings/password.scss +++ b/frontend/src/app/main/ui/settings/password.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "./profile" as *; diff --git a/frontend/src/app/main/ui/settings/profile.cljs b/frontend/src/app/main/ui/settings/profile.cljs index 1fd133d794..1c1add422b 100644 --- a/frontend/src/app/main/ui/settings/profile.cljs +++ b/frontend/src/app/main/ui/settings/profile.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.profile (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/settings/profile.scss b/frontend/src/app/main/ui/settings/profile.scss index 5d42d44c1e..1a6fca03c6 100644 --- a/frontend/src/app/main/ui/settings/profile.scss +++ b/frontend/src/app/main/ui/settings/profile.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "common/refactor/common-refactor" as *; diff --git a/frontend/src/app/main/ui/settings/restore_shortcuts_modal.scss b/frontend/src/app/main/ui/settings/restore_shortcuts_modal.scss index acfe0ee27f..78677d7d4b 100644 --- a/frontend/src/app/main/ui/settings/restore_shortcuts_modal.scss +++ b/frontend/src/app/main/ui/settings/restore_shortcuts_modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/settings/shortcuts.cljs b/frontend/src/app/main/ui/settings/shortcuts.cljs index 7800403adc..bb1ba9d383 100644 --- a/frontend/src/app/main/ui/settings/shortcuts.cljs +++ b/frontend/src/app/main/ui/settings/shortcuts.cljs @@ -93,10 +93,18 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def ^:private known-shortcut-keys - "Known shortcut keys per context, derived from the default shortcuts maps." - {:workspace (set (keys wsc/shortcuts)) - :dashboard (set (keys dsc/shortcuts)) - :viewer (set (keys vsc/shortcuts))}) + "Known shortcut keys per context, derived from the default shortcuts maps. + Shortcuts marked :customizable false are excluded." + (letfn [(collect-keys [shortcuts] + (reduce-kv (fn [s k v] + (if (false? (:customizable v)) + s + (conj s k))) + #{} + shortcuts))] + {:workspace (into (collect-keys psc/shortcuts) (collect-keys wsc/shortcuts)) + :dashboard (collect-keys dsc/shortcuts) + :viewer (collect-keys vsc/shortcuts)})) (def ^:private schema:imported-shortcuts "Malli schema for an imported custom-shortcuts payload. @@ -373,7 +381,8 @@ (mf/with-memo [] (fn [_ shortcut search-term] (or (str/blank? search-term) - (matches-search (:translation shortcut) search-term)))) + (matches-search (:translation shortcut) search-term) + (matches-search (ss/shortcut->command-string shortcut) search-term)))) filter-personalized (mf/use-fn @@ -385,8 +394,10 @@ customized? (and (contains? group-map shortcut-key) (not (str/blank? (get group-map shortcut-key))))] (and customized? + (not (false? (:customizable shortcut))) (or (str/blank? search-term) - (matches-search (:translation shortcut) search-term)))))) + (matches-search (:translation shortcut) search-term) + (matches-search (ss/shortcut->command-string shortcut) search-term)))))) filter-disabled (mf/use-fn @@ -398,8 +409,10 @@ in-group? (contains? group-map shortcut-key) blank? (str/blank? (get group-map shortcut-key))] (and in-group? blank? + (not (false? (:customizable shortcut))) (or (str/blank? search-term) - (matches-search (:translation shortcut) search-term)))))) + (matches-search (:translation shortcut) search-term) + (matches-search (ss/shortcut->command-string shortcut) search-term)))))) on-import-file (mf/use-fn @@ -414,11 +427,16 @@ on-export (mf/use-fn - (mf/deps shortcuts-json has-custom-shortcuts) + (mf/deps shortcuts-json has-custom-shortcuts (:fullname profile)) (fn [] (when has-custom-shortcuts - (->> (wapi/create-blob shortcuts-json "application/json") - (dom/trigger-download "penpot-shortcuts.json"))))) + (let [fullname (-> (or (:fullname profile) "user") + (str/replace #"[^a-zA-Z0-9\-_ ]" "") + (str/replace #"\s+" "_")) + date (.slice (.toISOString (js/Date.)) 0 10) + filename (str "penpot-shortcuts-" fullname "-" date ".json")] + (->> (wapi/create-blob shortcuts-json "application/json") + (dom/trigger-download filename)))))) on-file-selected (mf/use-fn diff --git a/frontend/src/app/main/ui/settings/shortcuts.scss b/frontend/src/app/main/ui/settings/shortcuts.scss index a3bfa3df78..522147f61e 100644 --- a/frontend/src/app/main/ui/settings/shortcuts.scss +++ b/frontend/src/app/main/ui/settings/shortcuts.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/settings/sidebar.cljs b/frontend/src/app/main/ui/settings/sidebar.cljs index 3ddff8f862..652842ff26 100644 --- a/frontend/src/app/main/ui/settings/sidebar.cljs +++ b/frontend/src/app/main/ui/settings/sidebar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.sidebar (:require-macros [app.main.style :as stl]) @@ -117,7 +117,8 @@ :data-testid "settings-profile"} [:span {:class (stl/css :element-title)} (tr "labels.settings")]] - (when (contains? cf/flags :subscriptions) + (when (or (contains? cf/flags :subscriptions) + (contains? cf/flags :admin-console)) [:li {:class (stl/css-case :current subscription? :settings-item true) :on-click go-settings-subscription diff --git a/frontend/src/app/main/ui/settings/sidebar.scss b/frontend/src/app/main/ui/settings/sidebar.scss index daf1e5f93b..e63882a113 100644 --- a/frontend/src/app/main/ui/settings/sidebar.scss +++ b/frontend/src/app/main/ui/settings/sidebar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/mixins.scss" as *; diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index 98626cda7d..957ffc6fa0 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -425,7 +425,7 @@ nitrate-toast-message (condp = params-subscription - dnt/nitrate-checkout-finish-error-token (tr "subscription.error.nitrate.checkout-finish-failed") + dnt/nitrate-checkout-finish-error-token (tr "subscription.error.nitrate.checkout-failed") dnt/nitrate-checkout-cancelled-token (tr "subscription.error.nitrate.checkout-cancelled") nil) @@ -745,7 +745,6 @@ :cta-link (if (and (contains? cf/flags :admin-console) nitrate?) #(open-contact-sales-modal subscription-type "Unlimited") #(open-subscription-modal "unlimited" subscription)) :cta-text-with-icon (tr "subscription.settings.more-information") :cta-link-with-icon go-to-pricing-page - :recommended (= subscription-type "professional") :show-button-cta (= subscription-type "professional") :current-plan false}]) @@ -783,6 +782,7 @@ :cta-text-with-icon (tr "subscription.settings.more-information") :cta-link-with-icon go-to-pricing-page :code-action :activate + :recommended (= subscription-type "professional") :show-button-cta (not nitrate-license) :current-plan false :inline-error nitrate-start-error-message}])]]])) @@ -908,7 +908,7 @@ (js/encodeURIComponent email) mailto-url - (dm/str "mailto:sales@penpot.net" + (dm/str "mailto:sales@penpot.app" "?subject=Request%20to%20Cancel%20Enterprise%20Subscription" "&body=Hello%2C%0A%0A" "I%20would%20like%20to%20cancel%20my%20Enterprise%20subscription.%0A" @@ -930,8 +930,8 @@ [:div {:class (stl/css :modal-content)} [:div {:class (stl/css :modal-text-medium)} (tr "nitrate.subscription.settings.manual-contact-us")] - [:a {:class (stl/css :cta-link) :href "mailto:sales@penpot.net"} - "sales@penpot.net"] + [:a {:class (stl/css :cta-link) :href "mailto:sales@penpot.app"} + "sales@penpot.app"] [:div {:class (stl/css :action-buttons)} [:> button* {:class (stl/css :button-full-width) :variant "primary" diff --git a/frontend/src/app/main/ui/settings/subscription.scss b/frontend/src/app/main/ui/settings/subscription.scss index 6130d7e3a3..ff7885506d 100644 --- a/frontend/src/app/main/ui/settings/subscription.scss +++ b/frontend/src/app/main/ui/settings/subscription.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/shapes/attrs.cljs b/frontend/src/app/main/ui/shapes/attrs.cljs index 118812762e..20defd27f0 100644 --- a/frontend/src/app/main/ui/shapes/attrs.cljs +++ b/frontend/src/app/main/ui/shapes/attrs.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.attrs (:require diff --git a/frontend/src/app/main/ui/shapes/bool.cljs b/frontend/src/app/main/ui/shapes/bool.cljs index dc8ce832e0..0066b4ca11 100644 --- a/frontend/src/app/main/ui/shapes/bool.cljs +++ b/frontend/src/app/main/ui/shapes/bool.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.bool (:require diff --git a/frontend/src/app/main/ui/shapes/circle.cljs b/frontend/src/app/main/ui/shapes/circle.cljs index ffceef78fb..8dc5452b36 100644 --- a/frontend/src/app/main/ui/shapes/circle.cljs +++ b/frontend/src/app/main/ui/shapes/circle.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.circle (:require diff --git a/frontend/src/app/main/ui/shapes/custom_stroke.cljs b/frontend/src/app/main/ui/shapes/custom_stroke.cljs index dfb0a5a1b8..aee07c11c8 100644 --- a/frontend/src/app/main/ui/shapes/custom_stroke.cljs +++ b/frontend/src/app/main/ui/shapes/custom_stroke.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.custom-stroke (:require diff --git a/frontend/src/app/main/ui/shapes/embed.cljs b/frontend/src/app/main/ui/shapes/embed.cljs index 0e396fcf4e..0665138730 100644 --- a/frontend/src/app/main/ui/shapes/embed.cljs +++ b/frontend/src/app/main/ui/shapes/embed.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.embed (:require diff --git a/frontend/src/app/main/ui/shapes/export.cljs b/frontend/src/app/main/ui/shapes/export.cljs index 46c9856ef6..f32b042779 100644 --- a/frontend/src/app/main/ui/shapes/export.cljs +++ b/frontend/src/app/main/ui/shapes/export.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.export "Components that generates penpot specific svg nodes with diff --git a/frontend/src/app/main/ui/shapes/fills.cljs b/frontend/src/app/main/ui/shapes/fills.cljs index c1be67a728..3b7c29da0b 100644 --- a/frontend/src/app/main/ui/shapes/fills.cljs +++ b/frontend/src/app/main/ui/shapes/fills.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.fills (:require diff --git a/frontend/src/app/main/ui/shapes/filters.cljs b/frontend/src/app/main/ui/shapes/filters.cljs index 9c3c9c3350..a4468d649d 100644 --- a/frontend/src/app/main/ui/shapes/filters.cljs +++ b/frontend/src/app/main/ui/shapes/filters.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.filters (:require diff --git a/frontend/src/app/main/ui/shapes/frame.cljs b/frontend/src/app/main/ui/shapes/frame.cljs index 9b181c4caa..d736635701 100644 --- a/frontend/src/app/main/ui/shapes/frame.cljs +++ b/frontend/src/app/main/ui/shapes/frame.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.frame (:require diff --git a/frontend/src/app/main/ui/shapes/gradients.cljs b/frontend/src/app/main/ui/shapes/gradients.cljs index f0f8bb620d..0845217dae 100644 --- a/frontend/src/app/main/ui/shapes/gradients.cljs +++ b/frontend/src/app/main/ui/shapes/gradients.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.gradients (:require diff --git a/frontend/src/app/main/ui/shapes/grid_layout_viewer.cljs b/frontend/src/app/main/ui/shapes/grid_layout_viewer.cljs index 0e34c276f9..3a72e4592a 100644 --- a/frontend/src/app/main/ui/shapes/grid_layout_viewer.cljs +++ b/frontend/src/app/main/ui/shapes/grid_layout_viewer.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.grid-layout-viewer (:require diff --git a/frontend/src/app/main/ui/shapes/group.cljs b/frontend/src/app/main/ui/shapes/group.cljs index ae836f2547..da7015297d 100644 --- a/frontend/src/app/main/ui/shapes/group.cljs +++ b/frontend/src/app/main/ui/shapes/group.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.group (:require diff --git a/frontend/src/app/main/ui/shapes/image.cljs b/frontend/src/app/main/ui/shapes/image.cljs index 04b6e0332d..9ec6223717 100644 --- a/frontend/src/app/main/ui/shapes/image.cljs +++ b/frontend/src/app/main/ui/shapes/image.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.image (:require diff --git a/frontend/src/app/main/ui/shapes/mask.cljs b/frontend/src/app/main/ui/shapes/mask.cljs index f84a040e95..90b53d8e40 100644 --- a/frontend/src/app/main/ui/shapes/mask.cljs +++ b/frontend/src/app/main/ui/shapes/mask.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.mask (:require diff --git a/frontend/src/app/main/ui/shapes/path.cljs b/frontend/src/app/main/ui/shapes/path.cljs index bec235c8d0..a39de86a98 100644 --- a/frontend/src/app/main/ui/shapes/path.cljs +++ b/frontend/src/app/main/ui/shapes/path.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.path (:require diff --git a/frontend/src/app/main/ui/shapes/rect.cljs b/frontend/src/app/main/ui/shapes/rect.cljs index 799d830b3f..6d205fca38 100644 --- a/frontend/src/app/main/ui/shapes/rect.cljs +++ b/frontend/src/app/main/ui/shapes/rect.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.rect (:require diff --git a/frontend/src/app/main/ui/shapes/shape.cljs b/frontend/src/app/main/ui/shapes/shape.cljs index ad30274607..c3600dc2d5 100644 --- a/frontend/src/app/main/ui/shapes/shape.cljs +++ b/frontend/src/app/main/ui/shapes/shape.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.shape (:require diff --git a/frontend/src/app/main/ui/shapes/svg_defs.cljs b/frontend/src/app/main/ui/shapes/svg_defs.cljs index b61bfad252..32d6a0b6c5 100644 --- a/frontend/src/app/main/ui/shapes/svg_defs.cljs +++ b/frontend/src/app/main/ui/shapes/svg_defs.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.svg-defs (:require diff --git a/frontend/src/app/main/ui/shapes/svg_raw.cljs b/frontend/src/app/main/ui/shapes/svg_raw.cljs index 12db38a3af..f5ad88abe3 100644 --- a/frontend/src/app/main/ui/shapes/svg_raw.cljs +++ b/frontend/src/app/main/ui/shapes/svg_raw.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.svg-raw (:require diff --git a/frontend/src/app/main/ui/shapes/text.cljs b/frontend/src/app/main/ui/shapes/text.cljs index e7581b6965..62e902ce78 100644 --- a/frontend/src/app/main/ui/shapes/text.cljs +++ b/frontend/src/app/main/ui/shapes/text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.text (:require diff --git a/frontend/src/app/main/ui/shapes/text/fo_text.cljs b/frontend/src/app/main/ui/shapes/text/fo_text.cljs index 5bb224b673..13927d2fce 100644 --- a/frontend/src/app/main/ui/shapes/text/fo_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/fo_text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.text.fo-text (:require @@ -79,15 +79,21 @@ {:type :gradient :gradient fill-color-gradient} - (and (string? fill-color) (some? fill-opacity) (not= fill-opacity 1)) + (and (string? fill-color) + (cc/hex-color-string? fill-color) + (some? fill-opacity) + (not= fill-opacity 1)) {:type :transparent :hex fill-color :opacity fill-opacity} - (string? fill-color) + (and (string? fill-color) + (cc/hex-color-string? fill-color)) {:type :solid :hex fill-color - :map-to fill-color})) + :map-to fill-color} + + :else nil)) (defn- retrieve-colors "Given a text shape returns a triple with the values: diff --git a/frontend/src/app/main/ui/shapes/text/fontfaces.cljs b/frontend/src/app/main/ui/shapes/text/fontfaces.cljs index 07160a45cc..e8c6776bb8 100644 --- a/frontend/src/app/main/ui/shapes/text/fontfaces.cljs +++ b/frontend/src/app/main/ui/shapes/text/fontfaces.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.text.fontfaces (:require diff --git a/frontend/src/app/main/ui/shapes/text/html_text.cljs b/frontend/src/app/main/ui/shapes/text/html_text.cljs index 5bf0dc150a..299ee49de7 100644 --- a/frontend/src/app/main/ui/shapes/text/html_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/html_text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.text.html-text (:require diff --git a/frontend/src/app/main/ui/shapes/text/styles.cljs b/frontend/src/app/main/ui/shapes/text/styles.cljs index edf5329ddf..657857f667 100644 --- a/frontend/src/app/main/ui/shapes/text/styles.cljs +++ b/frontend/src/app/main/ui/shapes/text/styles.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.text.styles (:require diff --git a/frontend/src/app/main/ui/shapes/text/svg_text.cljs b/frontend/src/app/main/ui/shapes/text/svg_text.cljs index aad09c18ba..a2fbdd1ea5 100644 --- a/frontend/src/app/main/ui/shapes/text/svg_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/svg_text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.text.svg-text (:require diff --git a/frontend/src/app/main/ui/shortcuts.cljs b/frontend/src/app/main/ui/shortcuts.cljs index 4499eac9ed..47454fa2c8 100644 --- a/frontend/src/app/main/ui/shortcuts.cljs +++ b/frontend/src/app/main/ui/shortcuts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shortcuts (:require-macros [app.main.style :as stl]) @@ -10,9 +10,13 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.config :as cf] + [app.main.data.dashboard.shortcuts :as dsc] [app.main.data.dashboard.shortcuts.customize :as customize] [app.main.data.profile :as du] [app.main.data.shortcuts :as ds] + [app.main.data.viewer.shortcuts :as vsc] + [app.main.data.workspace.path.shortcuts :as psc] + [app.main.data.workspace.shortcuts :as wsc] [app.main.store :as st] [app.main.ui.context :as ctx] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] @@ -107,14 +111,24 @@ (def ^:private import-contexts [:workspace :dashboard :viewer]) +(def ^:private context->known-keys + {:workspace (into #{} (concat (keys psc/shortcuts) (keys wsc/shortcuts))) + :dashboard (into #{} (keys dsc/shortcuts)) + :viewer (into #{} (keys vsc/shortcuts))}) + +(defn- build-context-shortcuts + [all-shortcuts ctx] + (let [known-keys (get context->known-keys ctx)] + (into {} (filter (fn [[k _]] (contains? known-keys k))) all-shortcuts))) + (defn- import-context-group "Imports a single context group from the payload, disabling any default shortcut whose command collides with a newly imported one, and any previously-imported entry in the same batch with a duplicate command." - [group all-shortcuts] + [group context-shortcuts] (reduce (fn [acc [command recorded-command]] - (let [default-conflict (find-conflict recorded-command all-shortcuts command) + (let [default-conflict (find-conflict recorded-command context-shortcuts command) acc-conflict (some (fn [[k v]] (when (and (not= k command) (= v recorded-command)) k)) @@ -136,7 +150,8 @@ new-customs (reduce (fn [acc ctx] (if (contains? shortcuts ctx) - (assoc acc ctx (import-context-group (get shortcuts ctx) all-shortcuts)) + (let [ctx-sc (build-context-shortcuts all-shortcuts ctx)] + (assoc acc ctx (import-context-group (get shortcuts ctx) ctx-sc))) acc)) current-customs import-contexts)] @@ -147,6 +162,20 @@ [type item] (map (fn [[k v]] [k (assoc v :translation (translation-keyname type k))]) item)) +(defn shortcut->command-string + "Extract a lowercase searchable string from a shortcut entry's key combo(s). + Prefers `:show-command` (display override) over `:command` (Mousetrap format), + matching what the keycap UI renders. Joins vector commands (key sequences) + with a space so every token is searchable. Returns \"\" when there is no + command (e.g. a section/subsection node)." + [shortcut] + (let [cmd (or (:show-command shortcut) (:command shortcut))] + (-> (cond + (nil? cmd) "" + (vector? cmd) (str/join " " cmd) + :else (str cmd)) + (str/lower)))) + (defn shortcuts->subsections [shortcuts] (let [subsections (into #{} (mapcat :subsections) (vals shortcuts)) @@ -576,11 +605,19 @@ [{:keys [elements filter-term is-match-section is-match-subsection editable? custom-shortcuts section-key conflicts hidden subsection-name]}] (let [shortcut-translations (->> elements vals (map :translation) sort) - match-shortcut? (some #(matches-search % filter-term) shortcut-translations) + match-shortcut? (some (fn [info] + (or (matches-search (:translation info) filter-term) + (matches-search (shortcut->command-string info) filter-term))) + (vals elements)) filtered (if (and (or is-match-section is-match-subsection) (not match-shortcut?)) shortcut-translations - (filter #(matches-search % filter-term) shortcut-translations)) - sorted-filtered (sort filtered) + (->> (vals elements) + (filter (fn [info] + (or (matches-search (:translation info) filter-term) + (matches-search (shortcut->command-string info) filter-term)))) + (map :translation) + sort)) + sorted-filtered filtered trigger-ref (mf/use-ref nil)] [:ul {:class (stl/css :sub-menu) @@ -597,8 +634,9 @@ (get custom-shortcuts section-key)) group-map (if (map? group-map) group-map {}) customized? (contains? group-map command) - has-conflict? (contains? conflicts command)] - (if editable? + has-conflict? (contains? conflicts command) + customizable? (not (false? (:customizable command-info)))] + (if (and editable? customizable?) [:> shortcut-row-editable* {:elements elements :custom-shortcuts custom-shortcuts :section-key section-key @@ -611,9 +649,13 @@ :data-conflict (str has-conflict?) :aria-label command-translate :key command-translate} - [:span {:class (stl/css :command-name) - :id (dm/str command-translate "-label")} - command-translate] + [:span + [:span {:class (stl/css-case :command-name true + :not-customizable-label (not customizable?)) + :id (dm/str command-translate "-label")} + command-translate] + (when (not customizable?) + [:span {:class (stl/css :not-customizable-label)} "(not customizable)"])] [:div {:class (stl/css :shortcut-actions) :aria-labelledby (dm/str command-translate "-label")} (if (and customized? (str/blank? content)) diff --git a/frontend/src/app/main/ui/shortcuts.scss b/frontend/src/app/main/ui/shortcuts.scss index 7d582e4e71..11eef8a482 100644 --- a/frontend/src/app/main/ui/shortcuts.scss +++ b/frontend/src/app/main/ui/shortcuts.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; @@ -21,7 +21,7 @@ .section-title, .subsection-title { - @include t.use-typography("title-small"); + @include t.use-typography("headline-small"); display: flex; align-items: center; @@ -43,6 +43,7 @@ } .subsection-title { + block-size: $sz-32; text-transform: none; padding-inline-start: var(--sp-m); } @@ -77,6 +78,10 @@ text-align: start; } +.not-customizable-label { + padding-inline-start: px2rem(6); +} + // Editable rows .shortcuts-name-editable { diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index 9e6dfa68f0..41f20a6710 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.static (:require-macros [app.main.style :as stl]) @@ -13,6 +13,7 @@ [app.common.uuid :as uuid] [app.main.data.auth :refer [is-authenticated?]] [app.main.data.common :as dcm] + [app.main.data.nitrate :as dnt] [app.main.errors :as errors] [app.main.refs :as refs] [app.main.repo :as rp] @@ -184,7 +185,7 @@ :on-click set-section} (tr "auth.login-here")]] [:div {:class (stl/css :links)} [:hr {:class (stl/css :separator)}] - [:> register/terms-register*]]] + [:> register/terms-service-privacy-policy*]]] :register-validate [:div {:class (stl/css :form-container)} @@ -433,43 +434,6 @@ (rx/of default) (rx/throw cause))))))) -(mf/defc exception-section* - {::mf/private true} - [{:keys [data] :as props}] - (let [type (get data :type) - cause (get data ::errors/instance) - - report (mf/with-memo [cause] - (when (ex/exception? cause) - (errors/generate-report cause))) - - props (mf/spread-props props {:report report})] - - (mf/with-effect [report type cause] - (when (and (ex/exception? cause) - (not (contains? #{:not-found :authentication} type))) - (errors/submit-report :event-name "exception-page" - :report report - :hint (ex/get-hint cause)))) - - (case type - :not-found - [:> not-found* {}] - - :authentication - [:> not-found* {}] - - :bad-gateway - [:> bad-gateway* props] - - :service-unavailable - [:> service-unavailable*] - - :nitrate-unavailable - [:> nitrate-unavailable*] - - [:> internal-error* props]))) - (mf/defc context-wrapper* [{:keys [is-workspace is-dashboard is-viewer profile children]}] [:* @@ -515,6 +479,99 @@ children]) +(mf/defc sso-error-section* + "Shown in place of the dashboard/workspace (same static skeleton and + `request-dialog*` used by the no-permission dialogs) when the organization + SSO exchange with the identity provider fails." + {::mf/private true} + [{:keys [organization-id team-id profile is-workspace is-dashboard organization-name]}] + (let [clean-url + (mf/with-memo [] + (-> (rt/get-current-href) + (dom/remove-query-param :sso-error) + (dom/remove-query-param :organization-id))) + + _ (mf/with-effect [] + ;; Consume the marker once: scrub it from the URL bar so a + ;; browser refresh doesn't keep re-showing this dialog. + (dom/replace-history-state! clean-url)) + + on-close + (mf/use-fn + (mf/deps profile) + (fn [] + ;; Land on the user's own default team + (st/emit! (rt/assign-exception nil) + (dcm/go-to-dashboard-recent :team-id (:default-team-id profile))))) + + on-retry + (mf/use-fn + (mf/deps organization-id team-id clean-url) + (fn [] + (st/emit! (rt/assign-exception nil)) + (if (or team-id organization-id) + ;; Retry with team-id and/or organization-id to trigger SSO check + (st/emit! (dnt/retry-organization-sso {:team-id team-id + :organization-id organization-id + :dest-url clean-url})) + ;; Fallback: just navigate to clean URL + (st/emit! (rt/nav-raw :uri clean-url)))))] + + [:> context-wrapper* {:is-dashboard (or is-dashboard (not is-workspace)) + :is-workspace is-workspace + :profile profile} + [:> request-dialog* {:title (tr "labels.sso-error.title", organization-name) + :content [(tr "labels.sso-error.desc-message")] + :button-text (tr "labels.sso-error.retry") + :on-button-click on-retry + :cancel-text (tr "not-found.no-permission.go-dashboard") + :on-close on-close}]])) + +(mf/defc exception-section* + {::mf/private true} + [{:keys [data] :as props}] + (let [type (get data :type) + cause (get data ::errors/instance) + + report (mf/with-memo [cause] + (when (ex/exception? cause) + (errors/generate-report cause))) + + props (mf/spread-props props {:report report})] + + (mf/with-effect [report type cause] + (when (and (ex/exception? cause) + (not (contains? #{:not-found :authentication} type))) + (errors/submit-report :event-name "exception-page" + :report report + :hint (ex/get-hint cause)))) + + (case type + :not-found + [:> not-found* {}] + + :authentication + [:> not-found* {}] + + :bad-gateway + [:> bad-gateway* props] + + :service-unavailable + [:> service-unavailable*] + + :nitrate-unavailable + [:> nitrate-unavailable*] + + :sso-error + [:> sso-error-section* {:organization-id (get data :organization-id) + :organization-name (get data :organization-name) + :team-id (get data :team-id) + :profile (mf/deref refs/profile) + :is-workspace (get data :is-workspace false) + :is-dashboard (get data :is-dashboard true)}] + + [:> internal-error* props]))) + (mf/defc exception-page* [{:keys [data route] :as props}] diff --git a/frontend/src/app/main/ui/static.scss b/frontend/src/app/main/ui/static.scss index 9f87bd9d3d..8c0e8d4516 100644 --- a/frontend/src/app/main/ui/static.scss +++ b/frontend/src/app/main/ui/static.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_borders.scss" as *; @@ -69,6 +69,7 @@ // SVG inside deco-before — no class available on the raw element .deco-before svg { position: absolute; + fill: var(--color-foreground-secondary); block-size: 1537px; inline-size: px2rem(80); inset-block-end: 0; @@ -76,6 +77,7 @@ // SVG inside deco-after2 — no class available on the raw element .deco-after2 svg { + fill: var(--color-foreground-secondary); block-size: 1537px; inline-size: px2rem(80); } diff --git a/frontend/src/app/main/ui/viewer.cljs b/frontend/src/app/main/ui/viewer.cljs index b1ae49b3b6..030216915b 100644 --- a/frontend/src/app/main/ui/viewer.cljs +++ b/frontend/src/app/main/ui/viewer.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/viewer.scss b/frontend/src/app/main/ui/viewer.scss index 15f0d46492..d727764672 100644 --- a/frontend/src/app/main/ui/viewer.scss +++ b/frontend/src/app/main/ui/viewer.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/viewer/comments.cljs b/frontend/src/app/main/ui/viewer/comments.cljs index e08d50cb5a..91ab7f82f2 100644 --- a/frontend/src/app/main/ui/viewer/comments.cljs +++ b/frontend/src/app/main/ui/viewer/comments.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.comments (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/viewer/comments.scss b/frontend/src/app/main/ui/viewer/comments.scss index 68169126f5..7fcd840b9e 100644 --- a/frontend/src/app/main/ui/viewer/comments.scss +++ b/frontend/src/app/main/ui/viewer/comments.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/viewer/header.cljs b/frontend/src/app/main/ui/viewer/header.cljs index 26369ae3af..3d1404373a 100644 --- a/frontend/src/app/main/ui/viewer/header.cljs +++ b/frontend/src/app/main/ui/viewer/header.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.header (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/viewer/header.scss b/frontend/src/app/main/ui/viewer/header.scss index 329a96a079..ffe476cbda 100644 --- a/frontend/src/app/main/ui/viewer/header.scss +++ b/frontend/src/app/main/ui/viewer/header.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/viewer/inspect.cljs b/frontend/src/app/main/ui/viewer/inspect.cljs index db8da10057..3ffed2dc99 100644 --- a/frontend/src/app/main/ui/viewer/inspect.cljs +++ b/frontend/src/app/main/ui/viewer/inspect.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.inspect (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/viewer/inspect.scss b/frontend/src/app/main/ui/viewer/inspect.scss index 38e12b01dd..5193c50f60 100644 --- a/frontend/src/app/main/ui/viewer/inspect.scss +++ b/frontend/src/app/main/ui/viewer/inspect.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/viewer/interactions.cljs b/frontend/src/app/main/ui/viewer/interactions.cljs index 19059f5fde..bd96266d37 100644 --- a/frontend/src/app/main/ui/viewer/interactions.cljs +++ b/frontend/src/app/main/ui/viewer/interactions.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.interactions (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/viewer/interactions.scss b/frontend/src/app/main/ui/viewer/interactions.scss index 104d39b707..b17ac08b84 100644 --- a/frontend/src/app/main/ui/viewer/interactions.scss +++ b/frontend/src/app/main/ui/viewer/interactions.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/viewer/login.cljs b/frontend/src/app/main/ui/viewer/login.cljs index 0371714419..436c35eef3 100644 --- a/frontend/src/app/main/ui/viewer/login.cljs +++ b/frontend/src/app/main/ui/viewer/login.cljs @@ -2,18 +2,21 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.login (:require-macros [app.main.style :as stl]) (:require [app.common.logging :as log] + [app.config :as cf] [app.main.data.modal :as modal] [app.main.store :as st] [app.main.ui.auth.login :refer [login-dialog*]] [app.main.ui.auth.recovery-request :refer [recovery-request-page*]] - [app.main.ui.auth.register :refer [register-methods* register-success-page* terms-register* register-validate-form*]] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.auth.register :refer [register-methods* register-success-page* + register-validate-form* terms-service-privacy-policy*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) @@ -24,14 +27,15 @@ {::mf/register modal/components ::mf/register-as :login-register} [_] - (let [user-email (mf/use-state "") + (let [user-email (mf/use-state "") register-token (mf/use-state "") current-section* (mf/use-state :login) - current-section (deref current-section*) + current-section (deref current-section*) set-current-section - (mf/use-fn #(reset! current-section* %)) + (mf/use-fn + #(reset! current-section* %)) set-section (mf/use-fn @@ -41,7 +45,9 @@ (keyword))] (set-current-section section)))) - go-back-to-login (mf/use-fn #(set-current-section :login)) + go-back-to-login + (mf/use-fn + #(set-current-section :login)) main-section (or (= current-section :login) @@ -51,13 +57,16 @@ (fn [event] (dom/prevent-default event) (st/emit! (modal/hide))) + success-email-sent (fn [email] (reset! user-email email) (set-current-section :email-sent)) + success-login (fn [] (.reload js/window.location true)) + success-register (fn [data] (reset! register-token (:token data)) @@ -66,48 +75,48 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} - [:h2 {:class (stl/css :modal-title)} (tr "labels.continue-with-penpot")] - [:button {:class (stl/css :modal-close-btn) - :title (tr "labels.close") - :on-click close} deprecated-icon/close]] + [:h2 {:class (stl/css :modal-header-title)} (tr "labels.continue-with-penpot")] + [:> icon-button* {:variant "ghost" + :class (stl/css :modal-close) + :aria-label (tr "labels.close") + :on-click close + :icon i/close}]] [:div {:class (stl/css :modal-content)} (case current-section :login - [:div {:class (stl/css :form-container)} - [:> login-dialog* - {:on-success-callback success-login - :origin :viewer}] - [:div {:class (stl/css :links)} - [:div {:class (stl/css :recovery-request)} + [:div {:class (stl/css :login-form)} + [:> login-dialog* {:on-success-callback success-login + :origin :viewer}] + [:div {:class (stl/css :login-links)} + [:div [:a {:on-click set-section - :class (stl/css :recovery-link) :data-value "recovery-request"} (tr "auth.forgot-password")]] - [:div {:class (stl/css :register)} - [:span {:class (stl/css :register-text)} - (tr "auth.register") " "] - [:a {:on-click set-section - :class (stl/css :register-link) - :data-value "register"} - (tr "auth.register-submit")]]]] + (when (contains? cf/flags :registration) + [:div + [:span + (tr "auth.register") " "] + [:a {:on-click set-section + :data-value "register"} + (tr "auth.register-submit")]])]] :register - [:div {:class (stl/css :form-container)} + [:div {:class (stl/css :login-form)} [:> register-methods* {:on-success-callback success-register}] - [:div {:class (stl/css :links)} - [:div {:class (stl/css :account)} + [:div {:class (stl/css :login-links)} + [:div [:span (tr "auth.already-have-account") " "] [:a {:on-click set-section :data-value "login"} (tr "auth.login-here")]]]] :register-validate - [:div {:class (stl/css :form-container)} + [:div {:class (stl/css :login-form)} [:> register-validate-form* {:params {:token @register-token} :on-success-callback success-email-sent}] - [:div {:class (stl/css :links)} - [:div {:class (stl/css :register)} + [:div {:class (stl/css :login-links)} + [:div [:a {:on-click set-section :data-value "register"} (tr "labels.go-back")]]]] @@ -115,10 +124,11 @@ :recovery-request [:> recovery-request-page* {:go-back-callback go-back-to-login :on-success-callback success-email-sent}] + :email-sent - [:div {:class (stl/css :form-container)} + [:div {:class (stl/css :login-form)} [:> register-success-page* {:params {:email @user-email}}]]) (when main-section - [:div {:class (stl/css :links)} - [:> terms-register*]])]]])) + [:div {:class (stl/css :login-links)} + [:> terms-service-privacy-policy*]])]]])) diff --git a/frontend/src/app/main/ui/viewer/login.scss b/frontend/src/app/main/ui/viewer/login.scss index 11cb81d678..92963aae79 100644 --- a/frontend/src/app/main/ui/viewer/login.scss +++ b/frontend/src/app/main/ui/viewer/login.scss @@ -2,77 +2,73 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_utils.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/typography.scss" as *; +@use "ds/z-index.scss" as *; .modal-overlay { - @extend %modal-overlay-base; + display: flex; + justify-content: center; + align-items: center; + position: fixed; + inset: 0; + block-size: 100%; + inline-size: 100%; + z-index: var(--z-index-set); + background-color: var(--color-overlay-default); } .modal-container { - @extend %modal-container-base; - - width: deprecated.$s-368; + position: relative; + padding: var(--sp-xxxl); + border-radius: $br-8; + background-color: var(--color-background-primary); + border: $b-2 solid var(--color-background-quaternary); + min-inline-size: $sz-364; + min-block-size: $sz-192; + max-inline-size: $sz-512; + max-block-size: $sz-712; + inline-size: $sz-364; } .modal-header { - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); } -.modal-title { - @include deprecated.uppercase-title-typography; +.modal-header-title { + @include use-typography("headline-small"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } -.modal-close-btn { - @extend %modal-close-btn-base; +.modal-close { + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-s); } .modal-content { - @include deprecated.flex-column; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - gap: deprecated.$s-24; - max-height: deprecated.$s-400; + display: flex; + flex-direction: column; + gap: var(--sp-xxl); + max-block-size: px2rem(576); overflow: hidden auto; - - form { - display: flex; - flex-direction: column; - margin-bottom: 1.5rem; - gap: 0.75rem; - } } -.form-container { +.login-form { display: flex; justify-content: center; flex-direction: column; + gap: var(--sp-m); } -.links { +.login-links { position: relative; -} - -.link-entry { - display: flex; - flex-direction: column; - gap: deprecated.$s-12; - - span { - text-align: center; - font-size: deprecated.$fs-14; - color: var(--modal-text-foreground-color); - margin-top: deprecated.$s-12; - } - - a { - @extend %button-secondary; - - height: deprecated.$s-40; - text-transform: uppercase; - font-size: deprecated.$fs-11; - } + color: var(--color-foreground-primary); } diff --git a/frontend/src/app/main/ui/viewer/shapes.cljs b/frontend/src/app/main/ui/viewer/shapes.cljs index 425ed01d18..4f3f0aa3d5 100644 --- a/frontend/src/app/main/ui/viewer/shapes.cljs +++ b/frontend/src/app/main/ui/viewer/shapes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.shapes "The main container for a frame in viewer mode" diff --git a/frontend/src/app/main/ui/viewer/share_link.cljs b/frontend/src/app/main/ui/viewer/share_link.cljs index 5b21805626..1711997594 100644 --- a/frontend/src/app/main/ui/viewer/share_link.cljs +++ b/frontend/src/app/main/ui/viewer/share_link.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.share-link (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/viewer/share_link.scss b/frontend/src/app/main/ui/viewer/share_link.scss index c3856361b4..43e2ba8910 100644 --- a/frontend/src/app/main/ui/viewer/share_link.scss +++ b/frontend/src/app/main/ui/viewer/share_link.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/viewer/thumbnails.cljs b/frontend/src/app/main/ui/viewer/thumbnails.cljs index ab6b4838fd..d7af5b5d41 100644 --- a/frontend/src/app/main/ui/viewer/thumbnails.cljs +++ b/frontend/src/app/main/ui/viewer/thumbnails.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.thumbnails (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/viewer/thumbnails.scss b/frontend/src/app/main/ui/viewer/thumbnails.scss index 20f569227d..63dc586a67 100644 --- a/frontend/src/app/main/ui/viewer/thumbnails.scss +++ b/frontend/src/app/main/ui/viewer/thumbnails.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/viewer/viewport_common.cljs b/frontend/src/app/main/ui/viewer/viewport_common.cljs index e2ebb1b881..efb5748a27 100644 --- a/frontend/src/app/main/ui/viewer/viewport_common.cljs +++ b/frontend/src/app/main/ui/viewer/viewport_common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.viewport-common "Shared object preparation for viewer viewports (SVG and WASM)." diff --git a/frontend/src/app/main/ui/viewer/viewport_wasm.cljs b/frontend/src/app/main/ui/viewer/viewport_wasm.cljs index 23d5b13490..e690edfdbf 100644 --- a/frontend/src/app/main/ui/viewer/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/viewer/viewport_wasm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.viewport-wasm (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index 278663bbcb..c626b36a29 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -2,13 +2,12 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace (:require-macros [app.main.style :as stl]) (:require [app.common.data.macros :as dm] - [app.config :as cf] [app.main.data.common :as dcm] [app.main.data.helpers :as dsh] [app.main.data.persistence :as dps] @@ -229,12 +228,8 @@ (st/emit! (dps/initialize-persistence) (dpl/update-plugins-permissions-peek))) - ;; FLAG :font-preview — prefetch the preview sprite markup on workspace mount - ;; (kept in memory, not the DOM) so the typography selector renders previews on - ;; open with no network wait. Remove the flag check to drop the feature. (mf/with-effect [] - (when (contains? cf/flags :font-preview) - (fonts/prefetch-preview-sprite!))) + (fonts/prefetch-preview-sprite!)) ;; Setting the layout preset by its name (mf/with-effect [layout-name] @@ -302,4 +297,3 @@ (when (uuid? file-id) [:> workspace* props]))) - diff --git a/frontend/src/app/main/ui/workspace.scss b/frontend/src/app/main/ui/workspace.scss index c0630368b8..afb89be6d2 100644 --- a/frontend/src/app/main/ui/workspace.scss +++ b/frontend/src/app/main/ui/workspace.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/color_palette.cljs b/frontend/src/app/main/ui/workspace/color_palette.cljs index 16425b2f83..1c95587fc4 100644 --- a/frontend/src/app/main/ui/workspace/color_palette.cljs +++ b/frontend/src/app/main/ui/workspace/color_palette.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.color-palette (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/color_palette.scss b/frontend/src/app/main/ui/workspace/color_palette.scss index 44d41b6304..fb0af2c527 100644 --- a/frontend/src/app/main/ui/workspace/color_palette.scss +++ b/frontend/src/app/main/ui/workspace/color_palette.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.cljs b/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.cljs index cf6e143f9b..4a67f0cdb1 100644 --- a/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.cljs +++ b/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.color-palette-ctx-menu (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss b/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss index 16b0428b4d..48fc511406 100644 --- a/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss +++ b/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/colorpicker.cljs b/frontend/src/app/main/ui/workspace/colorpicker.cljs index 0498e2d656..b5613862af 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker (:require-macros [app.main.style :as stl]) @@ -28,10 +28,12 @@ [app.main.ui.components.file-uploader :refer [file-uploader]] [app.main.ui.components.radio-buttons :refer [radio-buttons radio-button]] [app.main.ui.components.select :refer [select]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.checkbox :refer [checkbox*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]] [app.main.ui.hooks :as hooks] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.colorpicker.color-inputs :refer [color-inputs*]] [app.main.ui.workspace.colorpicker.color-tokens :refer [token-section*]] [app.main.ui.workspace.colorpicker.gradients :refer [gradients*]] @@ -433,10 +435,12 @@ (when (and (not= selected-mode :image) (= color-style :direct-color)) - [:button {:class (stl/css-case :picker-btn true - :selected picking-color?) - :on-click handle-click-picker} - deprecated-icon/picker]) + [:> icon-button* {:icon i/picker + :variant "ghost" + :aria-label (tr "workspace.colorpicker.color-picker") + :aria-pressed picking-color? + :class (stl/css :picker-btn) + :on-click handle-click-picker}]) (when (= color-style :token-color) [:div {:class (stl/css :token-color-title)} @@ -467,25 +471,19 @@ [:div {:class (stl/css :select-image)} [:div {:class (stl/css :content)} (when (:image current-color) - [:img {:src uri}])] + [:img {:src uri + :class (stl/css :content-image)}])] (when (some? (:image current-color)) [:div {:class (stl/css :checkbox-option)} - [:label {:for "keep-aspect-ratio" - :class (stl/css-case :global/checked keep-aspect-ratio?)} - [:span {:class (stl/css-case :global/checked keep-aspect-ratio?)} - (when keep-aspect-ratio? - deprecated-icon/status-tick)] - (tr "media.keep-aspect-ratio") - [:input {:type "checkbox" - :id "keep-aspect-ratio" - :checked keep-aspect-ratio? - :on-change handle-change-keep-aspect-ratio}]]]) - [:button - {:class (stl/css :choose-image) - :title (tr "media.choose-image") - :aria-label (tr "media.choose-image") - :on-click on-fill-image-click} + [:> checkbox* {:id "keep-aspect-ratio" + :checked keep-aspect-ratio? + :on-change handle-change-keep-aspect-ratio + :label (tr "media.keep-aspect-ratio")}]]) + + [:> button* {:class (stl/css :choose-image) + :variant "secondary" + :on-click on-fill-image-click} (tr "media.choose-image") [:& file-uploader {:input-id "fill-image-upload" @@ -554,11 +552,10 @@ :color-origin color-origin}])] (when (fn? on-accept) [:div {:class (stl/css :actions)} - [:button {:class (stl/css-case - :accept-color true - :btn-disabled disabled-color-accept?) - :on-click on-color-accept - :disabled disabled-color-accept?} + [:> button* {:class (stl/css :accept-color) + :variant "primary" + :on-click on-color-accept + :disabled disabled-color-accept?} (tr "workspace.libraries.colors.save-color")]])])) (defn calculate-position diff --git a/frontend/src/app/main/ui/workspace/colorpicker.scss b/frontend/src/app/main/ui/workspace/colorpicker.scss index b9fe67d6f5..09e5c5c41a 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker.scss @@ -2,26 +2,28 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; -@use "ds/spacing"; @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; -@use "refactor/basic-rules.scss" as *; .colorpicker-tooltip { - @extend %modal-background; - --colorpicker-width: #{$sz-284}; - left: calc(10 * px2rem(140)); - padding: var(--sp-m); - width: var(--colorpicker-width); - overflow: auto; + position: absolute; display: flex; flex-direction: column; + inset-inline-start: calc(10 * px2rem(140)); + inline-size: var(--colorpicker-width); + padding: var(--sp-m); + border-radius: $br-8; + box-shadow: 0 0 12px 0 var(--color-shadow-dark); + color: var(--color-foreground-primary); + background-color: var(--color-background-primary); + z-index: var(--z-index-set); + overflow: auto; } .colorpicker { @@ -34,7 +36,7 @@ align-items: flex-start; flex-direction: row-reverse; justify-content: space-between; - height: $sz-40; + block-size: $sz-40; } .top-actions-right { @@ -42,114 +44,14 @@ gap: var(--sp-s); } -.opacity-input-wrapper { - @extend %input-element; - @include t.use-typography("body-small"); - - width: px2rem(68); -} - -// TODO: change to DS button component -.picker-btn { - display: flex; - justify-content: center; - align-items: center; - background: none; - cursor: pointer; - background-color: transparent; - border: $b-1 solid transparent; - height: var(--sp-xl); - width: var(--sp-xl); - border-radius: $br-4; - padding: 0; - margin-top: var(--sp-xs); - - svg { - @extend %button-icon; - - stroke: var(--button-tertiary-foreground-color-rest); - } - - &:hover { - svg { - stroke: var(--button-tertiary-foreground-color-focus); - } - } - - &:focus, - &:focus-visible { - outline: none; - - svg { - stroke: var(--button-secondary-foreground-color-hover); - } - } - - &:active { - outline: none; - border: $b-1 solid transparent; - - svg { - stroke: var(--button-tertiary-foreground-color-active); - } - } - - &.selected { - svg { - stroke: var(--button-tertiary-foreground-color-active); - } - } -} - -.gradient-buttons { - display: flex; - align-items: center; - gap: var(--sp-s); -} - -.gradient-btn { - @extend %button-tertiary; - - height: var(--sp-xl); - width: var(--sp-xl); - border-radius: $br-4; - border: $b-2 solid transparent; - - &:hover { - border: $b-2 solid var(--colorpicker-details-color-selected); - } -} - -.linear-gradient-btn { - background: linear-gradient(180deg, var(--color-foreground-secondary), transparent); - - &.selected { - background: linear-gradient(to bottom, rgb(126 255 245 / 1) 0%, rgb(126 255 245 / 0.2) 100%); - border: $b-2 solid var(--colorpicker-details-color-selected); - } -} - -.radial-gradient-btn { - background: radial-gradient(transparent, var(--color-foreground-secondary)); - - &.selected { - background: radial-gradient(rgb(126 255 245 / 1) 0%, rgb(126 255 245 / 0.2) 100%); - border: $b-2 solid var(--colorpicker-details-color-selected); - } -} - .actions { display: flex; gap: var(--sp-xs); } .accept-color { - @include t.use-typography("headline-small"); - @extend %button-primary; - - width: 100%; - height: var(--sp-xxxl); - margin-top: var(--sp-s); + justify-content: center; + inline-size: 100%; } .picker-detail-wrapper { @@ -157,17 +59,18 @@ justify-content: center; align-items: center; position: relative; - margin: var(--sp-m) 0 var(--sp-s) 0; + margin-block: var(--sp-m) var(--sp-s); + margin-inline: 0; } .center-circle { - width: var(--sp-xxl); - height: var(--sp-xxl); - border: $b-2 solid var(--colorpicker-details-color); - border-radius: $br-circle; position: absolute; - left: 50%; - top: 50%; + inset-inline-start: 50%; + inset-block-start: 50%; + inline-size: var(--sp-xxl); + block-size: var(--sp-xxl); + border: $b-2 solid var(--color-background-quaternary); + border-radius: $br-circle; transform: translate(calc(-1 * var(--sp-m)), calc(-1 * var(--sp-m))); } @@ -177,46 +80,43 @@ } .select { - width: px2rem(116); + inline-size: px2rem(116); } .select-image { - margin-top: var(--sp-xs); + margin-block-start: var(--sp-xs); } .content { - border-radius: $br-8; display: flex; justify-content: center; + border-radius: $br-8; + block-size: px2rem(140); + margin-block-end: px2rem(6); + margin-inline-end: px2rem(1); background-image: url("/images/colorpicker-no-image.png"); background-position: center; background-size: auto px2rem(140); - height: px2rem(140); - margin-bottom: $sz-6; - margin-right: $sz-1; +} - img { - height: fit-content; - width: fit-content; - max-height: 100%; - max-width: 100%; - margin: auto; - } +.content-image { + max-inline-size: 100%; + max-block-size: 100%; + inline-size: fit-content; + block-size: fit-content; + margin: auto; } .choose-image { - @extend %button-secondary; - @include t.use-typography("headline-small"); - - width: 100%; - margin-top: var(--sp-m); - height: var(--sp-xxxl); + justify-content: center; + inline-size: 100%; } .checkbox-option { - @extend %input-checkbox; - - margin: var(--sp-l) 0 0 0; + display: flex; + align-items: center; + margin-block: var(--sp-l) var(--sp-m); + margin-inline: 0; } .token-color-title { @@ -225,5 +125,5 @@ color: var(--color-foreground-secondary); display: flex; align-items: center; - height: var(--sp-xxxl); + block-size: var(--sp-xxxl); } diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs index c7ce9d351c..5d7d72cf71 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker.color-inputs (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.scss b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.scss index d27bd7da31..cf7f1f9202 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs b/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs index 56042f6285..8fcf108bb7 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker.color-tokens (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.scss b/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.scss index 1b0eb76dad..aca7b3b4b9 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs b/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs index 7d1c7f14cc..8338544f73 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker.gradients (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss b/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss index 5539820871..0ce4019fd4 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs b/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs index 3b0800645b..c63c69ba45 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker.harmony (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/harmony.scss b/frontend/src/app/main/ui/workspace/colorpicker/harmony.scss index 6f05eb1179..d84eb19ae8 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/harmony.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/harmony.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs b/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs index 3532cfbc12..5194488b5e 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker.hsva (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/hsva.scss b/frontend/src/app/main/ui/workspace/colorpicker/hsva.scss index 99ba2dfd59..9bb07a1725 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/hsva.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/hsva.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs index 5e01c95785..474c40d60d 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker.libraries (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/libraries.scss b/frontend/src/app/main/ui/workspace/colorpicker/libraries.scss index 63b7d44d63..ad51293d73 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/libraries.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/libraries.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs b/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs index 01804c409c..fc4861678e 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker.ramp (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/ramp.scss b/frontend/src/app/main/ui/workspace/colorpicker/ramp.scss index 63a2b4973c..afac0687cc 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/ramp.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/ramp.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/shortcuts.cljs b/frontend/src/app/main/ui/workspace/colorpicker/shortcuts.cljs index e8f3855d6d..684cdd6d45 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/shortcuts.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/shortcuts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker.shortcuts (:require diff --git a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs index 14c4e1b820..8aff65e5d5 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker.slider-selector (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.scss b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.scss index fc34824198..1f134745cc 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/comments.cljs b/frontend/src/app/main/ui/workspace/comments.cljs index 64882acde7..a6596bf3b6 100644 --- a/frontend/src/app/main/ui/workspace/comments.cljs +++ b/frontend/src/app/main/ui/workspace/comments.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.comments (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/comments.scss b/frontend/src/app/main/ui/workspace/comments.scss index 5fb9de3068..685ac5c0df 100644 --- a/frontend/src/app/main/ui/workspace/comments.scss +++ b/frontend/src/app/main/ui/workspace/comments.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/components_debugger.cljs b/frontend/src/app/main/ui/workspace/components_debugger.cljs index d4aee9d90b..911ecb3cd5 100644 --- a/frontend/src/app/main/ui/workspace/components_debugger.cljs +++ b/frontend/src/app/main/ui/workspace/components_debugger.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.components-debugger (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/components_debugger.scss b/frontend/src/app/main/ui/workspace/components_debugger.scss index 2dd49527a7..cb02282db8 100644 --- a/frontend/src/app/main/ui/workspace/components_debugger.scss +++ b/frontend/src/app/main/ui/workspace/components_debugger.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index bd2758a02c..4e910f3624 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.context-menu "A workspace specific context menu (mouse right click)." @@ -510,7 +510,10 @@ :shortcut-key :start-editing :on-click do-start-editing}]) - (when-not (or disable-flatten has-frame? has-path?) + ;; Flattening a single path bakes its transform. + (when (and (not disable-flatten) + (not has-frame?) + (or (not has-path?) (and single? has-path?))) [:> menu-entry* {:title (tr "workspace.shape.menu.flatten") :on-click do-transform-to-path}]) diff --git a/frontend/src/app/main/ui/workspace/context_menu.scss b/frontend/src/app/main/ui/workspace/context_menu.scss index a2fe8c1b73..3061e59a4b 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.scss +++ b/frontend/src/app/main/ui/workspace/context_menu.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils" as *; @use "ds/_sizes" as *; diff --git a/frontend/src/app/main/ui/workspace/coordinates.cljs b/frontend/src/app/main/ui/workspace/coordinates.cljs index 3948479ac1..f626c8549c 100644 --- a/frontend/src/app/main/ui/workspace/coordinates.cljs +++ b/frontend/src/app/main/ui/workspace/coordinates.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.coordinates (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/coordinates.scss b/frontend/src/app/main/ui/workspace/coordinates.scss index b5664d49d6..b81087f712 100644 --- a/frontend/src/app/main/ui/workspace/coordinates.scss +++ b/frontend/src/app/main/ui/workspace/coordinates.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/left_header.cljs b/frontend/src/app/main/ui/workspace/left_header.cljs index 218b9df4b3..51fd5c0a3e 100644 --- a/frontend/src/app/main/ui/workspace/left_header.cljs +++ b/frontend/src/app/main/ui/workspace/left_header.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.left-header (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/left_header.scss b/frontend/src/app/main/ui/workspace/left_header.scss index 20c731dd86..63edc7ba5e 100644 --- a/frontend/src/app/main/ui/workspace/left_header.scss +++ b/frontend/src/app/main/ui/workspace/left_header.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/libraries.cljs b/frontend/src/app/main/ui/workspace/libraries.cljs index 0a2f5908f2..17a896c286 100644 --- a/frontend/src/app/main/ui/workspace/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/libraries.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.libraries (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/libraries.scss b/frontend/src/app/main/ui/workspace/libraries.scss index 01cbed12aa..21a7ad9a10 100644 --- a/frontend/src/app/main/ui/workspace/libraries.scss +++ b/frontend/src/app/main/ui/workspace/libraries.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index bb2c6a2d88..77c35c7f22 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.main-menu (:require-macros [app.main.style :as stl]) @@ -344,7 +344,7 @@ (mf/defc view-menu* {::mf/private true ::mf/wrap [mf/memo]} - [{:keys [layout toggle-flag on-close]}] + [{:keys [layout toggle-flag on-close on-close-all]}] (let [read-only? (mf/use-ctx ctx/workspace-read-only?) toggle-color-palette @@ -365,11 +365,11 @@ toggle-comments-visibility (mf/use-fn - (mf/deps on-close) + (mf/deps on-close-all) (fn [event] (dom/stop-propagation event) (st/emit! (dwcm/toggle-comments-visibility {:origin "workspace:menu"})) - (on-close)))] + (on-close-all)))] [:> dropdown-menu* {:show true :class (stl/css :base-menu :sub-menu :pos-3) @@ -927,13 +927,6 @@ (keyword))] (reset! selected-sub-menu* menu)))) - on-power-up-click - (mf/use-fn - (fn [] - (st/emit! (ev/event {::ev/name "explore-pricing-click" - ::ev/origin "workspace:menu"})) - (dom/open-new-window "https://penpot.app/pricing"))) - toggle-flag (mf/use-fn (fn [event] @@ -1130,21 +1123,10 @@ [:> icon* {:icon-id i/arrow-right :class (stl/css :item-arrow)}]] - (when (and (contains? cf/flags :subscriptions) - (not= "enterprise" subscription-type)) - [:> main-menu-power-up* {:close-sub-menu close-sub-menu}]) - - ;; TODO remove this block when subscriptions is full implemented - (when (contains? cf/flags :subscriptions-old) - [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) - :on-click on-power-up-click - :on-key-down (fn [event] - (when (kbd/enter? event) - (on-power-up-click))) - :on-pointer-enter close-sub-menu - :id "file-menu-power-up"} - [:span {:class (stl/css :item-name)} - (tr "subscription.workspace.header.menu.option.power-up")]])] + (when (or (and (contains? cf/flags :subscriptions) + (not= "enterprise" subscription-type)) + (contains? cf/flags :admin-console)) + [:> main-menu-power-up* {:close-sub-menu close-sub-menu}])] (case selected-sub-menu :file @@ -1157,7 +1139,8 @@ :view [:> view-menu* {:layout layout :toggle-flag toggle-flag - :on-close close-sub-menu}] + :on-close close-sub-menu + :on-close-all close-all-menus}] :preferences [:> preferences-menu* {:layout layout diff --git a/frontend/src/app/main/ui/workspace/main_menu.scss b/frontend/src/app/main/ui/workspace/main_menu.scss index 83a724622e..1ff9364c6c 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.scss +++ b/frontend/src/app/main/ui/workspace/main_menu.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/z-index.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/nudge.cljs b/frontend/src/app/main/ui/workspace/nudge.cljs index 52975c33d9..7c2ee4f01c 100644 --- a/frontend/src/app/main/ui/workspace/nudge.cljs +++ b/frontend/src/app/main/ui/workspace/nudge.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.nudge (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/nudge.scss b/frontend/src/app/main/ui/workspace/nudge.scss index 8b00f1f360..892de2576d 100644 --- a/frontend/src/app/main/ui/workspace/nudge.scss +++ b/frontend/src/app/main/ui/workspace/nudge.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/palette.cljs b/frontend/src/app/main/ui/workspace/palette.cljs index 7b5d5250cc..7887add48d 100644 --- a/frontend/src/app/main/ui/workspace/palette.cljs +++ b/frontend/src/app/main/ui/workspace/palette.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.palette (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/palette.scss b/frontend/src/app/main/ui/workspace/palette.scss index a96a3be2f3..b61814d2ba 100644 --- a/frontend/src/app/main/ui/workspace/palette.scss +++ b/frontend/src/app/main/ui/workspace/palette.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/spacing.scss" as *; @use "ds/z-index.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/plugins.cljs b/frontend/src/app/main/ui/workspace/plugins.cljs index 2206d15a24..41cf242d8d 100644 --- a/frontend/src/app/main/ui/workspace/plugins.cljs +++ b/frontend/src/app/main/ui/workspace/plugins.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.plugins (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/plugins.scss b/frontend/src/app/main/ui/workspace/plugins.scss index 25785192d3..b1aa38ff8e 100644 --- a/frontend/src/app/main/ui/workspace/plugins.scss +++ b/frontend/src/app/main/ui/workspace/plugins.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/presence.cljs b/frontend/src/app/main/ui/workspace/presence.cljs index 175148d0fd..2d7a3b69c6 100644 --- a/frontend/src/app/main/ui/workspace/presence.cljs +++ b/frontend/src/app/main/ui/workspace/presence.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.presence (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/presence.scss b/frontend/src/app/main/ui/workspace/presence.scss index 27eaa9300e..1eafdf6dbd 100644 --- a/frontend/src/app/main/ui/workspace/presence.scss +++ b/frontend/src/app/main/ui/workspace/presence.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/right_header.cljs b/frontend/src/app/main/ui/workspace/right_header.cljs index ff5e6a2152..54d1be1889 100644 --- a/frontend/src/app/main/ui/workspace/right_header.cljs +++ b/frontend/src/app/main/ui/workspace/right_header.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.right-header (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/right_header.scss b/frontend/src/app/main/ui/workspace/right_header.scss index 65302c705d..2b338659aa 100644 --- a/frontend/src/app/main/ui/workspace/right_header.scss +++ b/frontend/src/app/main/ui/workspace/right_header.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/shapes.cljs b/frontend/src/app/main/ui/workspace/shapes.cljs index cd8c3c3739..960094e712 100644 --- a/frontend/src/app/main/ui/workspace/shapes.cljs +++ b/frontend/src/app/main/ui/workspace/shapes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes "A workspace specific shapes wrappers. diff --git a/frontend/src/app/main/ui/workspace/shapes/bool.cljs b/frontend/src/app/main/ui/workspace/shapes/bool.cljs index f2fb063893..36174edf46 100644 --- a/frontend/src/app/main/ui/workspace/shapes/bool.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/bool.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.bool (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/common.cljs b/frontend/src/app/main/ui/workspace/shapes/common.cljs index 46dcf859e5..7d5031dc85 100644 --- a/frontend/src/app/main/ui/workspace/shapes/common.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.common (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/debug.cljs b/frontend/src/app/main/ui/workspace/shapes/debug.cljs index 5904b18318..0211df92d4 100644 --- a/frontend/src/app/main/ui/workspace/shapes/debug.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/debug.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.debug (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/frame.cljs b/frontend/src/app/main/ui/workspace/shapes/frame.cljs index 27346b3178..84344e6af2 100644 --- a/frontend/src/app/main/ui/workspace/shapes/frame.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/frame.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.frame (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs b/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs index adf587e057..237ce41434 100644 --- a/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.frame.dynamic-modifiers (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/group.cljs b/frontend/src/app/main/ui/workspace/shapes/group.cljs index 25a5b4f176..d9544e5358 100644 --- a/frontend/src/app/main/ui/workspace/shapes/group.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/group.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.group (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/path.cljs b/frontend/src/app/main/ui/workspace/shapes/path.cljs index 9f2a3699b9..33bd9acab0 100644 --- a/frontend/src/app/main/ui/workspace/shapes/path.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/path.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.path (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/path/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/path/editor.cljs index 34b00faf93..ef886cf5dc 100644 --- a/frontend/src/app/main/ui/workspace/shapes/path/editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/path/editor.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.path.editor (:require @@ -12,14 +12,16 @@ [app.common.types.path :as path] [app.common.types.path.helpers :as path.helpers] [app.main.data.workspace.path :as drp] + [app.main.data.workspace.path.helpers :as dwp.helpers] [app.main.snap :as snap] [app.main.store :as st] [app.main.streams :as ms] [app.main.ui.css-cursors :as cur] [app.main.ui.hooks :as hooks] + [app.main.ui.workspace.viewport.viewport-ref :as uwvv] [app.util.dom :as dom] [app.util.keyboard :as kbd] - [clojure.set :refer [map-invert]] + [beicon.v2.core :as rx] [goog.events :as events] [rumext.v2 :as mf])) @@ -39,10 +41,55 @@ (def black-color "var(--app-black)") (def white-color "var(--app-white)") (def gray-color "var(--df-secondary)") +(def selected-color "var(--app-pink)") + +;; Hover cursors for each edit mode and modifier combination. + +(defn- node-cursor + [edit-mode {:keys [shift? mod? alt?]} is-selected any-node-selected?] + (if (= edit-mode :draw) + (cond + ^boolean alt? "draw-remove" + ^boolean mod? "move-handles" + :else "draw-node") + (cond + (and ^boolean mod? ^boolean alt?) "draw-remove" + ^boolean mod? "move-handles" + ^boolean alt? "draw-remove" + (and ^boolean shift? + ^boolean any-node-selected?) "move-add" + ^boolean is-selected "move-move" + :else "move-node"))) + +(defn- segment-cursor + [edit-mode {:keys [shift? mod? alt?]} is-selected insert-preview?] + (if (= edit-mode :draw) + (cond + ^boolean alt? "draw-remove" + ^boolean mod? "move-curve" + :else "draw-add") + (cond + (and ^boolean mod? ^boolean alt?) "draw-remove" + ^boolean mod? "move-curve" + ^boolean alt? "draw-add" + ^boolean shift? "move-add" + ^boolean insert-preview? "draw-add" + ^boolean is-selected "move-move" + :else nil))) + +(defn- handler-cursor + [edit-mode {:keys [shift? mod? alt?]} is-selected] + (cond + (and ^boolean mod? ^boolean alt?) "move" + (or ^boolean mod? ^boolean alt?) "move-remove" + (and (= edit-mode :move) + ^boolean shift? + (not ^boolean is-selected)) "move-add" + :else "move")) (mf/defc path-point* {::mf/private true} - [{:keys [position zoom edit-mode is-hover is-selected is-preview is-start-path is-last is-new is-curve]}] + [{:keys [index position zoom edit-mode is-hover is-selected is-preview is-new cursor]}] (let [{:keys [x y]} position is-draw (= edit-mode :draw) @@ -54,48 +101,33 @@ on-enter (mf/use-fn + (mf/deps index) (fn [_] - (st/emit! (drp/path-pointer-enter position)))) + (when (some? index) + (st/emit! (drp/path-pointer-enter index))))) on-leave (mf/use-fn + (mf/deps index) (fn [_] - (st/emit! (drp/path-pointer-leave position)))) + (when (some? index) + (st/emit! (drp/path-pointer-leave index))))) on-pointer-down (fn [event] (when (dom/left-mouse? event) + (uwvv/capture-pointer event) (dom/stop-propagation event) (dom/prevent-default event) + (let [is-shift (kbd/shift? event) + is-alt (kbd/alt? event) + is-mod (kbd/mod? event)] + (cond + is-move + (st/emit! (drp/start-move-path-point index is-shift is-alt is-mod)) - ;; When clicking on a hover point that lies on a segment (has metadata with - ;; split params), only insert the node — don't also run draw-mode actions which - ;; would add the same position as an extra endpoint, corrupting the path order - ;; and misplacing stroke caps. - ;; FIXME: revisit this, using meta here breaks equality checks - (if (and is-new (some? (meta position))) - (st/emit! (drp/create-node-at-position (meta position))) - (let [is-shift (kbd/shift? event) - is-mod (kbd/mod? event)] - (cond - is-last - (st/emit! (drp/reset-last-handler)) - - (and is-move is-mod (not is-curve)) - (st/emit! (drp/make-curve position)) - - (and is-move is-mod is-curve) - (st/emit! (drp/make-corner position)) - - is-move - ;; If we're dragging a selected item we don't change the selection - (st/emit! (drp/start-move-path-point position is-shift)) - - (and is-draw is-start-path) - (st/emit! (drp/start-path-from-point position)) - - (and is-draw (not is-start-path)) - (st/emit! (drp/close-path-drag-start position)))))))] + is-draw + (st/emit! (drp/on-draw-node-pointer-down index position is-alt is-mod))))))] [:g.path-point [:circle.path-point @@ -108,7 +140,7 @@ :stroke (cond ^boolean is-active black-color ^boolean is-preview secondary-color :else accent-color) - :fill (cond is-selected accent-color + :fill (cond is-selected selected-color :else white-color)}}] [:circle {:cx x :cy y @@ -116,21 +148,24 @@ :on-pointer-down on-pointer-down :on-pointer-enter on-enter :on-pointer-leave on-leave - :pointer-events (when-not ^boolean is-preview "visible") - :class (cond ^boolean is-draw (cur/get-static "pen-node") - ^boolean is-move (cur/get-static "pointer-node")) + ;; Let insertion preview clicks reach the segment. + :pointer-events (cond ^boolean is-preview nil + ^boolean is-new "none" + :else "visible") + :class (when (some? cursor) (cur/get-static cursor)) :style {:stroke-width 0 :fill "none"}}]])) -;; FIXME: is-selected prop looks unused - (mf/defc path-handler* {::mf/private true} - [{:keys [index prefix point handler zoom is-selected is-hover edit-mode snap-angle]}] + [{:keys [index prefix point handler zoom is-selected is-hover snap-angle cursor on-grab]}] (let [x (dm/get-prop handler :x) y (dm/get-prop handler :y) - is-draw (= edit-mode :draw) - is-move (= edit-mode :move) + + ;; Placed handlers and handlers with `on-grab` are interactive. + is-interactive + (or (some? index) + (some? on-grab)) is-active (or ^boolean is-selected @@ -148,16 +183,21 @@ on-pointer-down (mf/use-fn - (mf/deps index prefix is-move) + (mf/deps index prefix is-interactive on-grab) (fn [event] - (when (dom/left-mouse? event) + (when (and ^boolean is-interactive (dom/left-mouse? event)) + (uwvv/capture-pointer event) (dom/stop-propagation event) (dom/prevent-default event) + (if (some? on-grab) + (on-grab event) + (st/emit! (drp/start-move-handler index + prefix + (kbd/shift? event) + (kbd/alt? event) + (kbd/mod? event)))))))] - (when ^boolean is-move - (st/emit! (drp/start-move-handler index prefix))))))] - - [:g.handler {:pointer-events (if ^boolean is-draw "none" "visible")} + [:g.handler {:pointer-events (if ^boolean is-interactive "visible" "none")} [:line {:x1 (:x point) :y1 (:y point) @@ -194,11 +234,70 @@ :on-pointer-down on-pointer-down :on-pointer-enter on-enter :on-pointer-leave on-leave - :class (when ^boolean is-move - (cur/get-static "pointer-move")) + :class (when (and ^boolean is-interactive (some? cursor)) + (cur/get-static cursor)) :style {:fill "none" :stroke-width 0}}]])) +(defn- segment-content + [{:keys [from to segment]}] + (path/content + [{:command :move-to + :params from} + (if (= :close-path (:command segment)) + {:command :line-to + :params to} + segment)])) + +(mf/defc path-segment* + {::mf/private true} + [{:keys [entry zoom edit-mode is-interactive is-selected is-hover cursor]}] + (let [index (:index entry) + content (mf/with-memo [entry] (segment-content entry)) + is-active (or ^boolean is-selected ^boolean is-hover) + on-enter (mf/use-fn + (mf/deps index) + (fn [_] + (st/emit! (drp/path-segment-enter index)))) + on-leave (mf/use-fn + (mf/deps index) + (fn [_] + (st/emit! (drp/path-segment-leave index)))) + on-pointer-down + (mf/use-fn + (mf/deps index is-interactive edit-mode) + (fn [event] + (when (and ^boolean is-interactive (dom/left-mouse? event)) + (uwvv/capture-pointer event) + (dom/stop-propagation event) + (dom/prevent-default event) + (if (= edit-mode :draw) + (st/emit! (drp/on-draw-segment-pointer-down index + (kbd/alt? event) + (kbd/mod? event))) + (st/emit! (drp/start-move-path-segment index + (kbd/shift? event) + (kbd/alt? event) + (kbd/mod? event)))))))] + [:g.path-segment {:pointer-events (if ^boolean is-interactive "visible" "none")} + (when ^boolean is-active + [:path {:d (.toString content) + :pointer-events "none" + :style {:fill "none" + :stroke (if ^boolean is-selected + selected-color + accent-color) + :stroke-width (/ 2 zoom)}}]) + [:path {:d (.toString content) + :on-pointer-down on-pointer-down + :on-pointer-enter on-enter + :on-pointer-leave on-leave + :pointer-events "stroke" + :class (when (some? cursor) (cur/get-static cursor)) + :style {:fill "none" + :stroke "transparent" + :stroke-width (/ point-radius-active-area zoom)}}]])) + (mf/defc path-preview* {::mf/private true} [{:keys [zoom segment from]}] @@ -229,12 +328,8 @@ (mf/defc path-snap* {::mf/private true} - [{:keys [selected points zoom]}] - (let [ranges - (mf/with-memo [selected points] - (snap/create-ranges points selected)) - - snap-matches + [{:keys [selected ranges zoom]}] + (let [snap-matches (snap/get-snap-delta-match selected ranges (/ 1 zoom)) matches @@ -262,34 +357,160 @@ angle (gpt/angle-with-other v1 v2)] (<= (- 180 angle) 0.1)))) +(defn- use-path-modifiers + "Tracks keyboard modifiers used by path cursors." + [] + (let [modifiers* (mf/use-state {:shift? false :mod? false :alt? false})] + (hooks/use-stream + (mf/with-memo [] + (rx/combine-latest ms/keyboard-shift ms/keyboard-mod ms/keyboard-alt)) + (fn [[shift? mod? alt?]] + (reset! modifiers* {:shift? (boolean shift?) + :mod? (boolean mod?) + :alt? (boolean alt?)}))) + (deref modifiers*))) + +(defn- use-insertion-preview + "Tracks the node insertion preview under the pointer." + [content zoom move-mode? mid-points] + (let [hover-point* (mf/use-state nil)] + (hooks/use-stream + (mf/with-memo [] + (rx/combine-latest ms/mouse-position ms/keyboard-mod ms/keyboard-shift ms/keyboard-alt)) + (mf/deps content zoom move-mode?) + (fn [[position mod? shift? alt?]] + (if (and ^boolean move-mode? + (not shift?) + (not mod?) + (gpt/point? position)) + (reset! hover-point* + (dwp.helpers/insertion-point + content position + (/ dwp.helpers/segment-insert-threshold zoom) + (boolean alt?) + mid-points)) + (reset! hover-point* nil)))) + (deref hover-point*))) + +(defn- create-snap-ranges + "Builds snap ranges from stationary nodes." + [content selected-nodes selected-segments include-all?] + (let [points (if include-all? + (path/get-points content) + (let [moving-indices (into selected-nodes + (dwp.helpers/segment-node-indices + content selected-segments)) + moving-positions (dwp.helpers/node-positions content moving-indices)] + (into [] (remove moving-positions) (path/get-points content))))] + (snap/create-ranges points))) + +(defn- snap-selected-points + [content selected-nodes selected-segment-nodes drag-handler preview moving-handler] + (cond + (some? drag-handler) #{drag-handler} + (some? preview) #{(path.helpers/segment->point preview)} + (some? moving-handler) #{moving-handler} + :else + (dwp.helpers/node-positions + content (into selected-nodes selected-segment-nodes)))) + +(mf/defc path-node* + {::mf/private true} + [{:keys [index position content handlers zoom edit-mode selected-nodes selected-handlers + hover-nodes hover-handlers moving-handler modifiers drag-cursor + any-node-selected]}] + (let [show-handler? (fn [[handler-index prefix]] + (not= position + (path/get-handler-point content handler-index prefix))) + point-handlers (->> (get handlers position) + (filter show-handler?) + (not-empty)) + point-selected? (contains? selected-nodes index) + point-hover? (contains? hover-nodes index) + matching-handlers? (matching-handler? content position point-handlers)] + [:g.path-node {:key (dm/str "node-" index)} + [:g.point-handlers + (for [[handler-index prefix] point-handlers] + (let [handler-position (path/get-handler-point content handler-index prefix) + handler-hover? (contains? hover-handlers [handler-index prefix]) + handler-selected? (contains? selected-handlers [handler-index prefix])] + (when (and position handler-position) + [:> path-handler* + {:key (dm/str handler-index "-" (d/name prefix)) + :point position + :handler handler-position + :index handler-index + :prefix prefix + :zoom zoom + :is-selected handler-selected? + :is-hover handler-hover? + :snap-angle (and (= handler-position moving-handler) matching-handlers?) + :edit-mode edit-mode + :cursor (or drag-cursor + (handler-cursor edit-mode modifiers handler-selected?))}])))] + + [:> path-point* {:index index + :position position + :zoom zoom + :edit-mode edit-mode + :is-selected point-selected? + :is-hover point-hover? + :cursor (or drag-cursor + (node-cursor edit-mode modifiers point-selected? + any-node-selected))}]])) + (mf/defc path-editor* [{:keys [shape zoom state]}] - (let [hover-point (mf/use-state nil) - editor-ref (mf/use-ref nil) + (let [editor-ref (mf/use-ref nil) {:keys [edit-mode drag-handler prev-handler preview content-modifiers - last-point - selected-points + selection moving-nodes moving-handler - hover-handlers - hover-points - snap-toggled]} + hover + snap-toggled + drag-cursor]} state - selected-points - (or selected-points #{}) + move-mode? + (= edit-mode :move) + + draw-mode? + (= edit-mode :draw) + + modifiers + (use-path-modifiers) + + selected-nodes (get selection :nodes #{}) + selected-segments (get selection :segments #{}) + selected-handlers (get selection :handlers #{}) + hover-nodes (get hover :nodes #{}) + hover-segments (get hover :segments #{}) + hover-handlers (get hover :handlers #{}) + + any-node-selected? + (boolean (seq selected-nodes)) + + ;; Skip segment hit targets while dragging. + dragging? + (or (some? drag-cursor) + (some? drag-handler)) base-content (get shape :content) - base-points - (mf/with-memo [base-content] - (path/get-points base-content)) + ;; Cache segment midpoints used by insertion previews. + insertion-mid-points + (mf/with-memo [base-content move-mode?] + (when move-mode? + (dwp.helpers/insertion-mid-points base-content))) + + hover-point + (use-insertion-preview base-content zoom move-mode? insertion-mid-points) content (mf/with-memo [base-content content-modifiers] @@ -299,12 +520,19 @@ (mf/with-memo [content] (path/get-points content)) - point->base (->> (map hash-map content-points base-points) (reduce merge)) - base->point (map-invert point->base) + ;; Pair each node position with its content index. + node-entries + (mf/with-memo [content content-points] + (mapv vector (dwp.helpers/node-indices content) content-points)) - points - (mf/with-memo [content-points] - (into #{} content-points)) + segment-entries + (mf/with-memo [content dragging?] + (when-not dragging? + (dwp.helpers/segment-entries content))) + + selected-segment-nodes + (mf/with-memo [content selected-segments] + (dwp.helpers/segment-node-indices content selected-segments)) last-p (->> content last path.helpers/segment->point) @@ -313,8 +541,16 @@ (mf/with-memo [content] (path/get-handlers content)) - is-path-start - (not (some? last-point)) + ;; Build snap ranges from stationary nodes. + snap-dragging-handler? + (boolean (or (some? drag-handler) + (some? preview) + (some? moving-handler))) + + snap-ranges + (mf/with-memo [base-content selected-nodes selected-segments snap-dragging-handler?] + (create-snap-ranges + base-content selected-nodes selected-segments snap-dragging-handler?)) show-snap? (and ^boolean snap-toggled @@ -329,23 +565,41 @@ (st/emit! :interrupt)))] #(events/unlistenByKey key))) - (hooks/use-stream - ms/mouse-position - (mf/deps base-content zoom) - (fn [position] - (when-let [point (path/closest-point base-content position (/ 0.01 zoom))] - (reset! hover-point (when (< (gpt/distance position point) (/ 10 zoom)) point))))) - [:g.path-editor {:ref editor-ref} [:path {:d (.toString content) :style {:fill "none" :stroke accent-color :strokeWidth (/ 1 zoom)}}] + (for [{:keys [index] :as entry} segment-entries] + (let [is-selected (or (contains? selected-segments index) + ;; Select segments between selected endpoints. + (and (contains? selected-nodes (:from-index entry)) + (contains? selected-nodes (:to-index entry)))) + is-hover (contains? hover-segments index)] + [:> path-segment* + {:key (dm/str "segment-" index) + :entry entry + :zoom zoom + :edit-mode edit-mode + :is-interactive (or ^boolean move-mode? ^boolean draw-mode?) + :is-selected is-selected + :is-hover is-hover + :cursor (or drag-cursor + (segment-cursor edit-mode modifiers is-selected + (and is-hover (some? hover-point))))}])) (when (and preview (not drag-handler)) [:> path-preview* {:segment preview :from last-p :zoom zoom}]) + ;; Let insertion preview clicks reach the segment. + (when (and ^boolean move-mode? (some? hover-point)) + [:g.hover-point {:pointer-events "none"} + [:> path-point* {:position hover-point + :edit-mode edit-mode + :is-new true + :zoom zoom}]]) + (when (and drag-handler last-p) [:g.drag-handler {:pointer-events "none"} [:> path-handler* {:point last-p @@ -353,90 +607,39 @@ :edit-mode edit-mode :zoom zoom}]]) - (when @hover-point - [:g.hover-point - [:> path-point* {:position @hover-point - :edit-mode edit-mode - :is-new true - :is-start-path is-path-start - :zoom zoom}]]) - - (for [position points] - (let [pos-x (dm/get-prop position :x) - pos-y (dm/get-prop position :y) - - show-handler? - (fn [[index prefix]] - ;; FIXME: get-handler-point is executed twice for each - ;; render, this can be optimized - (let [handler-position (path/get-handler-point content index prefix)] - (not= position handler-position))) - - position-handlers - (->> (get handlers position) - (filter show-handler?) - (not-empty)) - - point-selected? - (contains? selected-points (get point->base position)) - - point-hover? - (contains? hover-points (get point->base position)) - - is-last - (= last-point (get point->base position)) - - is-curve - (boolean position-handlers)] - - [:g.path-node {:key (dm/str pos-x "-" pos-y)} - [:g.point-handlers {:pointer-events (when (= edit-mode :draw) "none")} - (for [[hindex prefix] position-handlers] - (let [handler-position (path/get-handler-point content hindex prefix) - handler-hover? (contains? hover-handlers [hindex prefix]) - moving-handler? (= handler-position moving-handler) - matching-handler? (matching-handler? content position position-handlers)] - - (when (and position handler-position) - [:> path-handler* - {:key (dm/str hindex "-" (d/name prefix)) - :point position - :handler handler-position - :index hindex - :prefix prefix - :zoom zoom - :is-hover handler-hover? - :snap-angle (and moving-handler? matching-handler?) - :edit-mode edit-mode}])))] - - [:> path-point* {:position position - :zoom zoom - :edit-mode edit-mode - :is-selected point-selected? - :is-hover point-hover? - :is-last is-last - :is-start-path is-path-start - :is-curve is-curve}]])) + (for [[index position] node-entries] + [:> path-node* {:key (dm/str "node-" index) + :index index + :position position + :content content + :handlers handlers + :zoom zoom + :edit-mode edit-mode + :selected-nodes selected-nodes + :selected-handlers selected-handlers + :hover-nodes hover-nodes + :hover-handlers hover-handlers + :moving-handler moving-handler + :modifiers modifiers + :drag-cursor drag-cursor + :any-node-selected any-node-selected?}]) (when (and prev-handler last-p) - [:g.prev-handler {:pointer-events "none"} + [:g.prev-handler [:> path-handler* {:point last-p :edit-mode edit-mode :handler prev-handler - :zoom zoom}]]) + :zoom zoom + :on-grab (fn [_] (st/emit! (drp/start-move-prev-handler))) + :cursor (or drag-cursor + (handler-cursor edit-mode modifiers false))}]]) (when ^boolean show-snap? - (let [[snap-selected snap-points] - (cond - (some? drag-handler) [#{drag-handler} points] - (some? preview) [#{(path.helpers/segment->point preview)} points] - (some? moving-handler) [#{moving-handler} points] - :else - [(->> selected-points (map base->point) (into #{})) - (->> points (remove selected-points) (into #{}))])] + (let [snap-selected (snap-selected-points + content selected-nodes selected-segment-nodes + drag-handler preview moving-handler)] [:g.path-snap {:pointer-events "none"} [:> path-snap* {:selected snap-selected - :points snap-points + :ranges snap-ranges :zoom zoom}]]))])) - diff --git a/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs b/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs index 6f304e17f3..e25224fd34 100644 --- a/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.svg-raw (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/text.cljs b/frontend/src/app/main/ui/workspace/shapes/text.cljs index 5a1dad63df..2faf1e9aa1 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.text (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs index 536febb61b..770a1ebec7 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.text.editor (:require @@ -30,18 +30,24 @@ ;; --- Text Editor Rendering -(mf/defc block-component* - [{:keys [block-props] :as props}] - (let [data (.-data ^js block-props) - style (sts/generate-paragraph-styles (.-shape ^js block-props) data) - dir (:text-direction data "auto")] +(mf/defc block-component + {::mf/wrap-props false} + [props] + (let [bprops (obj/get props "blockProps") + data (obj/get bprops "data") + style (sts/generate-paragraph-styles (obj/get bprops "shape") + (obj/get bprops "data")) + dir (:text-direction data "auto")] + [:div {:style style :dir dir} [:> draft/EditorBlock props]])) -(mf/defc selection-component* - [{:keys [children]}] - [:span {:style {:background "#ccc" :display "inline-block"}} children]) +(mf/defc selection-component + {::mf/wrap-props false} + [props] + (let [children (obj/get props "children")] + [:span {:style {:background "#ccc" :display "inline-block"}} children])) (defn- render-block [block shape] @@ -49,7 +55,7 @@ (case type "unstyled" #js {:editable true - :component block-component* + :component block-component :props #js {:data (ted/get-editor-block-data block) :shape shape}} nil))) @@ -63,7 +69,7 @@ (sts/generate-text-styles shape data {:show-text? false}))) (def default-decorator - (ted/create-decorator "PENPOT_SELECTION" selection-component*)) + (ted/create-decorator "PENPOT_SELECTION" selection-component)) (def empty-editor-state (ted/create-editor-state nil default-decorator)) @@ -89,11 +95,12 @@ "bottom" "flex-end" nil)) -(mf/defc text-shape-edit-html* +(mf/defc text-shape-edit-html {::mf/wrap [mf/memo] + ::mf/wrap-props false ::mf/forward-ref true} - [{:keys [shape]} _] - (let [{:keys [id content]} shape + [props _] + (let [{:keys [id content] :as shape} (obj/get props "shape") state-map (mf/deref refs/workspace-editor-state) state (get state-map id empty-editor-state) @@ -268,7 +275,8 @@ (-> (gpt/subtract pt box) (gpt/multiply zoom))))) -(mf/defc text-editor-svg* +(mf/defc text-editor-svg + {::mf/wrap-props false} [{:keys [shape modifiers]}] (let [shape-id (dm/get-prop shape :id) modifiers (dm/get-in modifiers [shape-id :modifiers]) @@ -341,6 +349,6 @@ [:foreignObject {:x x :y y :width width :height height} [:div {:style style} - [:> text-shape-edit-html* + [:& text-shape-edit-html {:shape shape :key (dm/str shape-id)}]]]])) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs b/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs index 35529a64a0..93e7562438 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs @@ -2,12 +2,11 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.text.text-edition-outline (:require [app.common.geom.shapes :as gsh] - [app.common.math :as mth] [app.main.data.helpers :as dsh] [app.main.data.workspace.texts :as dwt] [app.main.features :as features] @@ -22,11 +21,13 @@ (let [selrect-transform (mf/deref refs/workspace-selrect) [selrect transform] (dsh/get-selrect selrect-transform shape) - [sr-width sr-height] - (if (or (mth/close? (:width selrect) 0.01) (mth/close? (:height selrect) 0.01)) - (let [{:keys [width height]} (wasm.api/get-text-dimensions (:id shape))] - [width height]) - [(:width selrect) (:height selrect)])] + ;; While editing, the committed selrect lags the text (geometry is + ;; finalize-only), so measure the live WASM text for the growing axes: + ;; width grows on auto-width, height on auto-width/auto-height. + grow-type (:grow-type shape) + {live-width :width live-height :height} (wasm.api/get-text-dimensions (:id shape)) + sr-width (if (= grow-type :auto-width) live-width (:width selrect)) + sr-height (if (= grow-type :fixed) (:height selrect) live-height)] [:rect.main.viewport-selrect {:x (:x selrect) :y (:y selrect) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs index 68da4e80b4..5a9f1f3a92 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.text.v2-editor (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index fb65be820e..fe0dc8a03b 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.text.v3-editor "Contenteditable DOM element for WASM text editor input" @@ -11,18 +11,36 @@ [app.common.data.macros :as dm] [app.common.types.text :as txt] [app.main.data.helpers :as dsh] + [app.main.data.workspace :as dw] [app.main.data.workspace.texts :as dwt] + [app.main.data.workspace.undo :as dwu] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.css-cursors :as cur] [app.render-wasm.api :as wasm.api] [app.render-wasm.text-editor :as text-editor] + [app.util.clipboard :as clipboard] [app.util.dom :as dom] + [app.util.keyboard :as kbd] [cuerdas.core :as str] [rumext.v2 :as mf])) (def caret-blink-interval-ms 250) +;; Elements carrying this attr keep the edit alive when focus moves onto them (see `keep-editing-on-blur?`). +(def ^:private keep-editing-selector "[data-keep-editing-on-blur]") + +(defn- keep-editing-on-blur? + "True when a surface `blur` must NOT exit the editor: + - Firefox triggering a blur when MacOS Character Viewer is open + - Focus switched to a data-keep-editing-on-blur region (e.g. typography options), + ancestors or descendants" + [^js event ^js surface] + (or (= (.-activeElement js/document) surface) + (when-let [related (dom/get-related-target event)] + (or (some? (.closest related keep-editing-selector)) + (some? (.querySelector related keep-editing-selector)))))) + (defn- sync-wasm-text-editor-content! "Sync WASM text editor content back to the shape via the standard commit pipeline. Called after every text-modifying input." @@ -39,6 +57,48 @@ :name name :finalize? finalize?))))) +;; Keys that move/reset the caret (or delete): pressing any abandons the pending +;; caret style. Plain character keys instead reach `on-input`, which consumes it. +(def ^:private caret-abandon-keys + #{"ArrowLeft" "ArrowRight" "ArrowUp" "ArrowDown" + "Home" "End" "PageUp" "PageDown" + "Enter" "Backspace" "Delete" "Escape" "Tab"}) + +(defn- caret-position + "Collapsed caret as {:para :offset} from the WASM selection, or nil." + [] + (when-let [{:keys [focus-para focus-offset]} (text-editor/text-editor-get-selection)] + {:para focus-para :offset focus-offset})) + +(defn- typed-range + "Normalized range covering the text inserted between `before` and `after`, or nil." + [before after] + (when (and before after) + (if (or (< (:para before) (:para after)) + (and (= (:para before) (:para after)) + (<= (:offset before) (:offset after)))) + {:start-para (:para before) :start-offset (:offset before) + :end-para (:para after) :end-offset (:offset after)} + {:start-para (:para after) :start-offset (:offset after) + :end-para (:para before) :end-offset (:offset before)}))) + +(defn- sync-with-pending-caret-styles! + "Commit an insertion that consumed a pending caret style: sync the new text, + then restyle the just-typed `range` into its own span. `before` is the + pre-insert caret." + [shape-id before] + (let [range (typed-range before (caret-position))] + ;; Sync first so the cached content stays index-aligned with WASM. + (text-editor/text-editor-sync-content) + (if-let [{:keys [content]} (wasm.api/apply-pending-caret-styles! shape-id range)] + (let [text (txt/content->text content) + name (when (not= text "") (txt/generate-shape-name text))] + (st/emit! (dwt/v2-update-text-shape-content + shape-id content + :update-name? true + :name name))) + (sync-wasm-text-editor-content!)))) + (defn- reset-input-node "Empties the contenteditable capture surface and restores a collapsed caret inside it. @@ -99,6 +159,26 @@ (or (.-isComposing native) (= 229 (.-keyCode event))))) +(defn- double-click? + [^js native-event] + (= (.-detail native-event) 2)) + +(defn- triple-click? + [^js native-event] + (>= (.-detail native-event) 3)) + +(defn- input-surface-class + "Class list for the contenteditable capture surface. + + Mousetrap's `stopCallback` drops every keystroke whose target is + contentEditable, so without the `mousetrap` class (as in V1/V2) the text + shortcuts (Ctrl+B, Ctrl+I, …) never reach the dispatcher." + [rotation] + (dm/str "mousetrap " + (cur/get-dynamic "text" rotation) + " " + (stl/css :text-editor-container))) + (mf/defc text-editor* "Contenteditable element positioned over the text shape to capture input events." [{:keys [shape]}] @@ -118,6 +198,8 @@ ;; WASM `is_pointer_selection_active` guard), not on every hover move. dragging-ref (mf/use-ref false) + deferred-press-ref (mf/use-ref nil) + fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false) fallback-families (map (fn [font] (font-family-from-font-id (:font-id font))) fallback-fonts) @@ -151,6 +233,8 @@ on-composition-start (mf/use-fn (fn [_event] + ;; IME composition supplies its own text; drop any pending caret style. + (text-editor/clear-pending-caret-styles!) (text-editor/text-editor-composition-start))) on-composition-update @@ -178,6 +262,8 @@ (mf/use-fn (fn [^js event] (dom/prevent-default event) + ;; Pasted text keeps the surrounding style; drop any pending caret style. + (text-editor/clear-pending-caret-styles!) (let [clipboard-data (.-clipboardData event) text (.getData clipboard-data "text/plain")] (when (and text (seq text)) @@ -191,19 +277,27 @@ (fn [^js event] (when (text-editor/text-editor-has-focus?) (dom/prevent-default event) - (when (text-editor/text-editor-get-selection) - (let [text (text-editor/text-editor-export-selection)] - (.setData (.-clipboardData event) "text/plain" text)))))) + (when (text-editor/text-editor-has-selection?) + (let [text (or (text-editor/text-editor-export-selection) "") + html (clipboard/plain-text->html text) + data (.-clipboardData event)] + ;; text/html matters on Windows: many apps prefer CF_HTML, and + ;; without it they can pick up the empty contenteditable `<br>`. + (.setData data "text/plain" text) + (.setData data "text/html" html)))))) on-cut (mf/use-fn (fn [^js event] (when (text-editor/text-editor-has-focus?) (dom/prevent-default event) - (when (text-editor/text-editor-get-selection) - (let [text (text-editor/text-editor-export-selection)] - (.setData (.-clipboardData event) "text/plain" (or text "")) - (when (and text (seq text)) + (when (text-editor/text-editor-has-selection?) + (let [text (or (text-editor/text-editor-export-selection) "") + html (clipboard/plain-text->html text) + data (.-clipboardData event)] + (.setData data "text/plain" text) + (.setData data "text/html" html) + (when (seq text) (text-editor/text-editor-delete-backward) (sync-wasm-text-editor-content!) (wasm.api/request-render-preserving-target "text-cut")))) @@ -217,13 +311,12 @@ (let [key (.-key event) ctrl? (or (.-ctrlKey event) (.-metaKey event)) shift? (.-shiftKey event)] + ;; Ctrl+A adds select-all to the caret-abandon-keys set. + (when (or (contains? caret-abandon-keys key) + (and ctrl? (= (str/lower key) "a"))) + (text-editor/clear-pending-caret-styles!)) (cond - ;; Escape: finalize and stop - (= key "Escape") - (do - (dom/prevent-default event) - (when-let [node (mf/ref-val contenteditable-ref)] - (.blur node))) + ;; NOTE: Escape is handled in a document key-up listener (see effect below). ;; Ctrl+A: select all (key is "a" or "A" depending on platform) (and ctrl? (= (str/lower key) "a")) @@ -256,6 +349,15 @@ (sync-wasm-text-editor-content!) (wasm.api/request-render-preserving-target "text-delete-forward")) + ;; Shift+Tab falls through to the browser, so the keyboard can + ;; still leave the editor. + (and (= key "Tab") (not shift?)) + (do + (dom/prevent-default event) + (text-editor/text-editor-insert-text "\t") + (sync-wasm-text-editor-content!) + (wasm.api/request-render-preserving-target "text-tab")) + ;; Insert (= key "Insert") (do @@ -344,8 +446,14 @@ (let [pending (mf/ref-val pending-replace-ref)] (dotimes [_ pending] (text-editor/text-editor-delete-backward))) - (text-editor/text-editor-insert-text data) - (sync-wasm-text-editor-content!) + (let [shape-id (text-editor/text-editor-get-active-shape-id) + ;; The inserted character adopts a pending caret style, if any. + pending-styles? (some? (text-editor/get-pending-caret-styles shape-id)) + before (when pending-styles? (caret-position))] + (text-editor/text-editor-insert-text data) + (if pending-styles? + (sync-with-pending-caret-styles! shape-id before) + (sync-wasm-text-editor-content!))) (wasm.api/request-render-preserving-target "text-input")) (mf/set-ref-val! pending-replace-ref 0) ;; IMPORTANT: do NOT clear the surface here (see keep-input-alive): @@ -357,18 +465,29 @@ (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event)] - (mf/set-ref-val! dragging-ref true) - (wasm.api/text-editor-pointer-down off-pt) - ;; Repaint the caret over the cached tiles instead of a full render, - ;; which flashes at high zoom (see `render-text-editor-overlay!`). - (wasm.api/render-text-editor-overlay!)))) + off-pt (dom/get-offset-position native-event)] + ;; Repositioning the caret abandons the pending caret style (also + ;; covers click and double-click, which fire pointer-down first). + (text-editor/clear-pending-caret-styles!) + (if (.-shiftKey event) + (do + (mf/set-ref-val! dragging-ref true) + (wasm.api/text-editor-pointer-down-extend off-pt) + ;; Repaint the caret over the cached tiles instead of a full + ;; render, which flashes at high zoom. + (wasm.api/render-text-editor-overlay!)) + (mf/set-ref-val! deferred-press-ref off-pt))))) on-pointer-move (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event)] + off-pt (dom/get-offset-position native-event)] + (when-let [pressed-pt (and (pos? (.-buttons native-event)) + (mf/ref-val deferred-press-ref))] + (mf/set-ref-val! deferred-press-ref nil) + (mf/set-ref-val! dragging-ref true) + (wasm.api/text-editor-pointer-down pressed-pt)) (wasm.api/text-editor-pointer-move off-pt) ;; Only while dragging: `text-editor-pointer-move` is a no-op ;; otherwise, so avoid repainting on plain hover. @@ -379,18 +498,37 @@ (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event)] + off-pt (dom/get-offset-position native-event) + dragging? (mf/ref-val dragging-ref)] (mf/set-ref-val! dragging-ref false) + (mf/set-ref-val! deferred-press-ref nil) (wasm.api/text-editor-pointer-up off-pt) - (wasm.api/render-text-editor-overlay!)))) + ;; Without a drag there is no pointer selection to close; the + ;; caret is placed by `on-click`. + (when dragging? + (wasm.api/render-text-editor-overlay!))))) on-click (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event)] - (wasm.api/text-editor-set-cursor-from-offset off-pt) - (wasm.api/render-text-editor-overlay!)))) + off-pt (dom/get-offset-position native-event)] + (cond + (triple-click? native-event) + (do + (wasm.api/text-editor-select-paragraph off-pt) + (wasm.api/render-text-editor-overlay!)) + + ;; `dblclick` selects the word right after. Shift+click still goes + ;; through: WASM consumes its skip-click flag there. + (and (double-click? native-event) + (not (.-shiftKey event))) + nil + + :else + (do + (wasm.api/text-editor-set-cursor-from-offset off-pt) + (wasm.api/render-text-editor-overlay!)))))) on-double-click (mf/use-fn @@ -407,15 +545,31 @@ on-blur (mf/use-fn - (fn [^js _event] - (sync-wasm-text-editor-content! {:finalize? true}) - (wasm.api/text-editor-blur))) + (fn [^js event] + ;; A blur exits the editor unless keep-editing-on-blur? is true + (when-not (and (some? event) + (keep-editing-on-blur? event (mf/ref-val contenteditable-ref))) + (text-editor/clear-pending-caret-styles!) + (sync-wasm-text-editor-content! {:finalize? true}) + (wasm.api/text-editor-blur)))) style #js {:pointerEvents "all" "--editor-container-width" (dm/str width "px") "--editor-container-height" (dm/str height "px") "--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")}] + ;; Exit on Escape via a document key-up listener (like v2). On key-down the trailing + ;; key-up is read as a non-editing Escape and deselects the shape. + (mf/use-effect + (mf/deps) + (fn [] + (let [on-key-up (fn [event] + (when (kbd/esc? event) + (dom/stop-propagation event) + (st/emit! (dw/clear-edition-mode))))] + (.addEventListener js/document "keyup" on-key-up) + #(.removeEventListener js/document "keyup" on-key-up)))) + ;; Register the native `beforeinput` listener. React's synthetic ;; `onBeforeInput` does not expose `getTargetRanges()`, even with ;; nativeEvent (it's fully synthetic, composed of other two events). @@ -433,6 +587,9 @@ (mf/use-effect (mf/deps contenteditable-ref) (fn [] + ;; Group the whole editing session (edits, reflow resizes, finalize) into a single + ;; undo entry. Nested transactions (e.g. style shortcuts) are ref-counted and fold in. + (st/emit! (dwu/start-undo-transaction shape-id :timeout nil)) (when-let [node (mf/ref-val contenteditable-ref)] ;; Focus and select all text on mount (this will trigger on-focus) (.focus node) @@ -443,6 +600,7 @@ ;; it was not being reliable (timing issues, Firefox issues…) (fn [] (on-blur) + (st/emit! (dwu/commit-undo-transaction shape-id)) (text-editor/text-editor-dispose) (wasm.api/request-render-preserving-target "text-editor-dispose")))) @@ -505,7 +663,5 @@ :on-focus on-focus :on-blur on-blur :id "text-editor-wasm-input" - :class (dm/str (cur/get-dynamic "text" (:rotation shape)) - " " - (stl/css :text-editor-container)) + :class (input-surface-class (:rotation shape)) :data-testid "text-editor-container"}]]]])) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs index 77d659c17b..3869f62a03 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.text.viewport-texts-html (:require @@ -12,6 +12,7 @@ [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.geom.shapes.text :as gsht] + [app.common.logging :as log] [app.common.math :as mth] [app.common.types.modifiers :as ctm] [app.common.types.text :as txt] @@ -96,9 +97,12 @@ (st/emit! (dwt/resize-text id width height))))) (st/emit! (dwt/clean-text-modifier id)))) - ;; Swallowed so a text whose position data cannot be computed still - ;; settles and still reports its measurement as finished. - (p/catch (fn [_] nil)))) + ;; Always clear the task and log measurement errors. + (p/catch (fn [cause] + (log/error :hint "Could not measure text shape" + :shape-id id + :cause cause) + nil)))) (defn- update-text-modifier [{:keys [grow-type id] :as shape} node] diff --git a/frontend/src/app/main/ui/workspace/sidebar.cljs b/frontend/src/app/main/ui/workspace/sidebar.cljs index d66dcdc4ff..d33272ccac 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar (:require-macros [app.main.style :as stl]) @@ -97,9 +97,7 @@ sitemap-height (if sitemap-collapsed? 32 height)] - [:article {:class (stl/css :layers-tab) - :style {:--height (dm/str height "px")}} - + [:article {:class (stl/css :layers-tab)} [:> sitemap* {:layout layout :height sitemap-height :collapsed sitemap-collapsed? diff --git a/frontend/src/app/main/ui/workspace/sidebar.scss b/frontend/src/app/main/ui/workspace/sidebar.scss index 66239eb9e9..63d129a1ab 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.scss +++ b/frontend/src/app/main/ui/workspace/sidebar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @@ -79,13 +79,15 @@ .layers-tab { padding-block-start: var(--sp-xs); - overflow-x: hidden; + display: flex; + flex-direction: column; + overflow: hidden; + min-block-size: 0; } .layers-tab-resize-area { background-color: var(--color-background-primary); - position: absolute; - inset-inline-start: 0; + flex: 0 0 auto; inline-size: 100%; padding: px2rem(3) 0 px2rem(1); block-size: $sz-6; @@ -120,11 +122,16 @@ .left-sidebar-content { grid-area: content; inset-inline-end: calc(-1 * var(--sp-s)); + min-block-size: 0; + overflow: hidden; } .left-sidebar-tabs { --tabs-nav-padding-inline-start: var(--sp-m); --tabs-nav-padding-inline-end: var(--sp-m); + + block-size: 100%; + min-block-size: 0; } .left-sidebar-resize-area { diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs index a6402a61aa..f345c514f2 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.assets (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.scss b/frontend/src/app/main/ui/workspace/sidebar/assets.scss index 079733bcb3..716d0682b8 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs index 853232c118..e1e70bb2e4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.assets.colors (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.scss index b590a71f6f..60dfffa9f2 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs index 086a21ae11..ba3ff9786e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.assets.common diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/common.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/common.scss index 140d100bfc..f3ba35dfa1 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/common.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/common.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs index 45a24f96e1..ab1bfb1137 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.assets.components (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/components.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/components.scss index a758213bc3..c09e1e4d0f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/components.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/components.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs index 5ee44ee19d..b0d9747a6e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.assets.file-library (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.scss index 864066bda7..266718c00b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs index 3be72a6c27..b072139f1e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.assets.groups (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss index 0238d8b50d..003db6e756 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs index 43627008bd..8d40408436 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.assets.typographies (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.scss index 4c841ffbae..593f0a6cbd 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/common/sidebar.scss b/frontend/src/app/main/ui/workspace/sidebar/common/sidebar.scss index 054c5ead0f..ffbb7d5e25 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/common/sidebar.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/common/sidebar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/spacing.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug.cljs b/frontend/src/app/main/ui/workspace/sidebar/debug.cljs index a7f6c48db0..51435e5779 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/debug.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.debug (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug.scss b/frontend/src/app/main/ui/workspace/sidebar/debug.scss index e358c7b9f4..da70295d97 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/debug.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs index f7c1e84ca9..92c9ae2e2b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.debug-shape-info (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss index 6760cf76a3..c849565bb5 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/history.cljs b/frontend/src/app/main/ui/workspace/sidebar/history.cljs index 1fc0b07464..e399b124ff 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/history.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/history.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.history (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/history.scss b/frontend/src/app/main/ui/workspace/sidebar/history.scss index a22bb5fc35..e7e8b1a2cd 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/history.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/history.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs index 446ef4187e..5733b8c3ce 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.layer-item (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss b/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss index 192a00f522..445b8e5bc9 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs index 3cd2038675..db497cf242 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.layer-name (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss b/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss index de465cac36..23a36b1d1b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index e9db4a2343..babc144cc9 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.layers (:require-macros [app.main.style :as stl]) @@ -438,7 +438,7 @@ navigate-next (mf/use-fn - (mf/deps text-match-count) + (mf/deps text-match-ids text-match-count) (fn [_] (when (pos? text-match-count) (let [ids (mf/ref-val text-match-ids-ref) @@ -447,11 +447,11 @@ (mf/set-ref-val! match-idx-ref next-idx) (swap! state* assoc :current-match-idx next-idx) (st/emit! (dw/select-shape id) - dw/zoom-to-selected-shape))))) + (dw/center-on-shape id)))))) navigate-prev (mf/use-fn - (mf/deps text-match-count) + (mf/deps text-match-ids text-match-count) (fn [_] (when (pos? text-match-count) (let [ids (mf/ref-val text-match-ids-ref) @@ -460,7 +460,7 @@ (mf/set-ref-val! match-idx-ref prev-idx) (swap! state* assoc :current-match-idx prev-idx) (st/emit! (dw/select-shape id) - dw/zoom-to-selected-shape))))) + (dw/center-on-shape id)))))) handle-replace (mf/use-fn diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.scss b/frontend/src/app/main/ui/workspace/sidebar/layers.scss index 328c92afdb..4ff85ab137 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/borders.scss" as *; @@ -23,7 +23,7 @@ align-items: center; justify-content: space-between; gap: var(--sp-xs); - margin: var(--sp-m) var(--sp-m) 0 var(--sp-m); + margin: px2rem(6) var(--sp-m) 0 var(--sp-m); } .tool-window-bar-title { @@ -31,11 +31,10 @@ } .tool-window-content { - --calculated-height: calc(#{px2rem(136)} + var(--height, #{$sz-200})); - display: flex; flex-direction: column; - block-size: calc(100vh - var(--calculated-height)); + flex: 1 1 auto; + min-block-size: 0; inline-size: calc(var(--left-sidebar-width) + var(--depth) * var(--layer-indentation-size)); overflow: auto; scrollbar-gutter: stable; @@ -117,6 +116,10 @@ .layers { position: relative; + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-block-size: 0; } .replace-wrapper { diff --git a/frontend/src/app/main/ui/workspace/sidebar/options.cljs b/frontend/src/app/main/ui/workspace/sidebar/options.cljs index f07dce3891..d607168954 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options (:require-macros [app.main.style :as stl]) @@ -15,6 +15,7 @@ [app.main.data.helpers :as dsh] [app.main.data.workspace :as udw] [app.main.data.workspace.common :as dwc] + [app.main.data.workspace.path.state :as path.state] [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] @@ -105,6 +106,18 @@ drawing (mf/deref refs/workspace-drawing) edition (mf/deref refs/selected-edition) + edit-path + (mf/deref refs/workspace-edit-path) + + edit-path-state + (path.state/current-edit-state edit-path edition) + + path-editing? + (path.state/editing? edit-path edition) + + path-node-count + (count (dm/get-in edit-path-state [:selection :nodes])) + files (mf/deref refs/files) @@ -152,12 +165,22 @@ [:div {:class (stl/css :element-options :design-options)} [:> align-options* {:shapes shapes - :objects objects}] - [:> bool-options* {:total-selected total-selected - :shapes shapes - :shapes-with-children shapes-with-children}] + :objects objects + :path-edit? path-editing? + :node-count path-node-count}] + (when-not path-editing? + [:> bool-options* {:total-selected total-selected + :shapes shapes + :shapes-with-children shapes-with-children}]) (cond + ;; Show path-specific options during node editing. + path-editing? + [:> path/path-edition-options* + {:shape (get objects edition) + :file-id file-id + :page-id page-id}] + (and edit-grid? (d/not-empty? selected-cells)) [:> grid-cell/options* {:shape-id (-> (get objects edition) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options.scss b/frontend/src/app/main/ui/workspace/sidebar/options.scss index 5b755e38a5..54b1702a1e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs index a334737206..84aa1325b8 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.common (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/common.scss b/frontend/src/app/main/ui/workspace/sidebar/options/common.scss index 209ee67afb..2f401be011 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/common.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/common.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .advanced-options-wrapper { display: flex; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/drawing.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/drawing.cljs index 87785b6f65..34f09999e3 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/drawing.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/drawing.cljs @@ -3,7 +3,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.drawing (:require diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs index 67c81dd494..2d5d384542 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.drawing.frame (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.scss b/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.scss index d4c166465e..71829e132f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs index f3e78e951e..3ae778553f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs @@ -2,12 +2,13 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.align (:require-macros [app.main.style :as stl]) (:require [app.main.data.workspace :as dw] + [app.main.data.workspace.path :as dwdp] [app.main.data.workspace.shortcuts :as sc] [app.main.store :as st] [app.main.ui.icons :as deprecated-icon] @@ -16,30 +17,44 @@ [rumext.v2 :as mf])) (mf/defc align-options* - [{:keys [shapes objects]}] - (let [disabled-align - (not (dw/can-align? shapes objects)) + ;; Align path nodes or whole shapes for the current edit mode. + [{:keys [shapes objects path-edit? node-count]}] + (let [node-count (or node-count 0) + + disabled-align + (if path-edit? + (< node-count 2) + (not (dw/can-align? shapes objects))) disabled-distribute - (not (dw/can-distribute? shapes)) + (if path-edit? + (< node-count 3) + (not (dw/can-distribute? shapes))) align-objects (mf/use-fn + (mf/deps path-edit?) (fn [event] (let [value (-> (dom/get-current-target event) (dom/get-data "value") (keyword))] - (st/emit! (dw/align-objects value))))) + (st/emit! (if path-edit? + (dwdp/align-nodes value) + (dw/align-objects value)))))) distribute-objects (mf/use-fn + (mf/deps path-edit?) (fn [event] (let [value (-> (dom/get-current-target event) (dom/get-data "value") (keyword))] - (st/emit! (dw/distribute-objects value)))))] + (st/emit! (if path-edit? + (dwdp/distribute-nodes value) + (dw/distribute-objects value))))))] - (when-not (and disabled-align disabled-distribute) + ;; Keep path controls visible while their actions are disabled. + (when (or path-edit? (not (and disabled-align disabled-distribute))) [:div {:class (stl/css :align-options)} [:div {:class (stl/css :align-group-horizontal)} [:button {:class (stl/css-case :align-button true @@ -106,4 +121,3 @@ :data-value "vertical" :on-click distribute-objects} deprecated-icon/distribute-vertical-spacing]]]))) - diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss index 2ca87d872c..135544aa70 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.cljs index 80ee55805a..62efb577e5 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.blur (:require-macros [app.main.style :as stl]) @@ -296,7 +296,7 @@ (if mixed-state [:div {:class (stl/css :first-row)} [:span {:class (stl/css :mixed-label)} - (tr "labels.mixed-values")] + (tr "settings.multiple")] [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.options.blur-options.remove-blur") :on-click handle-delete-all diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.scss index deb2054058..c1524b82c3 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.cljs index 6fb87a8b47..46801115ad 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.bool (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss index 5166aa8ac8..50c8ae0625 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.scss index 61ce6b4049..6b6e64d30d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography" as t; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs index e6f9e6010c..cda718388c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.color-selection (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss index 72ce645929..15da32944b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index 74e2eecb2d..5036ad87a7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.component (:require-macros [app.main.style :as stl]) @@ -290,14 +290,18 @@ get-components-with-duplicated-variant-props-and-values (map :main-instance-id))) +(defn- get-variant-option + [val] + {:id val + :label (if (str/blank? val) (str "(" (tr "labels.empty") ")") val)}) + (defn- get-variant-options "Get variant options for a given property name" [prop-name prop-vals] (->> (filter #(= (:name %) prop-name) prop-vals) first :value - (mapv (fn [val] {:id val - :label (if (str/blank? val) (str "(" (tr "labels.empty") ")") val)})))) + (mapv get-variant-option))) (mf/defc component-variant-property* [{:keys [pos prop options on-prop-name-blur on-prop-value-change on-reorder]}] @@ -527,8 +531,12 @@ (for [[pos prop] (map-indexed vector props-first)] (let [mixed-value? (not-every? #(= (:value prop) (:value (get % pos))) properties) base-options (get options-by-name (:name prop)) + no-options? (empty? base-options) boolean-pair (ctv/find-boolean-pair (mapv :id base-options)) options (cond-> base-options + no-options? + (conj (get-variant-option (:value prop))) + mixed-value? (conj {:id mixed-label :label mixed-label :dimmed true}))] @@ -549,6 +557,7 @@ [:> select* {:default-selected (if mixed-value? mixed-label (:value prop)) :options options :empty-to-end true + :disabled no-options? :on-change (partial switch-component pos) :key (str (:value prop) "-" key)}]])]))] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss index cb738df65d..ca8bbd7aab 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs index 0e89cb166c..216dfec3f3 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.constraints (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.scss index 6ba682c5c4..20b13505b9 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.cljs index 84886d3f0c..25a4ca08ca 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.exports (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.scss index c0b36c4252..92aa2ab196 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs index 4141143a6e..8863bbeedc 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.fill (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.scss index f497a4ced0..fe412c7867 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs index 66b12b9955..608a25c537 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.frame-grid (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss index 3647ffbdf8..8f97f4dc42 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs index 34e27560de..2434c66c07 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.grid-cell (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss index d8406725e3..8c245a491f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.scss index 0747e67692..16fe94c005 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .numeric-input-wrapper { --dropdown-width: var(--seven-columns-width); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs index 6b5f537bfc..f6bebb8d8f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.interactions (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.scss index cffa05d73b..1375dd986f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs index 980a2aa336..c74fbdca56 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.layer (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.scss index e00abf5e98..ff8da843ff 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs index c02b3e6b90..14f89f38b9 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.layout-container (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss index 985eb8adb2..7182dc5729 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL // TODO: When button replace remove this @use @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs index 63da937d98..10b9c42db7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.layout-item (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss index 2daf441b27..b362df51d0 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs index f4a454af54..7b1b258a2c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.measures (:require-macros [app.main.style :as stl]) @@ -176,6 +176,29 @@ (identical? (get o-values :proportion-lock) (get n-values :proportion-lock))))) +(mf/defc node-position-menu* + "X and Y inputs for the current path selection." + [{:keys [values on-x-change on-y-change]}] + ;; Match the shape position input layout. + [:section {:class (stl/css :element-set)} + [:div {:class (stl/css :position)} + [:div {:class (stl/css :x-position) + :title (tr "workspace.options.x")} + [:span {:class (stl/css :icon-text)} "X"] + [:> deprecated-input/numeric-input* {:no-validate true + :placeholder (if (= :multiple (:x values)) (tr "settings.multiple") "--") + :on-change on-x-change + :class (stl/css :numeric-input) + :value (:x values)}]] + [:div {:class (stl/css :y-position) + :title (tr "workspace.options.y")} + [:span {:class (stl/css :icon-text)} "Y"] + [:> deprecated-input/numeric-input* {:no-validate true + :placeholder (if (= :multiple (:y values)) (tr "settings.multiple") "--") + :on-change on-y-change + :class (stl/css :numeric-input) + :value (:y values)}]]]]) + (mf/defc measures-menu* {::mf/wrap [#(mf/memo' % check-measures-menu-props)]} [{:keys [ids values applied-tokens type shapes]}] @@ -374,7 +397,7 @@ (fn [value attr] (if (or (string? value) (number? value)) (st/emit! (udw/trigger-bounding-box-cloaking ids) - (udw/update-dimensions ids attr value)) + (udw/update-dimensions-coalesced ids attr value)) (st/emit! (udw/trigger-bounding-box-cloaking ids) (dwta/apply-token-from-input {:token (first value) :attrs #{attr} @@ -408,7 +431,7 @@ (if (or (string? value) (number? value)) (let [value (fixed-decimal-value value)] (st/emit! (udw/trigger-bounding-box-cloaking ids)) - (st/emit! (udw/increase-rotation ids value))) + (st/emit! (udw/increase-rotation-coalesced ids value))) (st/emit! (udw/trigger-bounding-box-cloaking ids) (dwta/apply-token-from-input {:token (first value) :attrs #{:rotation} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss index b2cdece8c3..8fa48d1bb0 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs index 9e5de7487e..9178b58558 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.shadow (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.scss index 0c3a1cea44..273459001c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs index d671e7294c..1bd5bd2270 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.stroke (:require-macros [app.main.style :as stl]) @@ -276,7 +276,7 @@ (seq strokes) [:> h/sortable-container* {} (for [[index value] (d/enumerate (:strokes values []))] - [:> stroke-row* {:key (dm/str "stroke-" index "-" (hash applied-tokens)) + [:> stroke-row* {:key (dm/str "stroke-" index) :index index :stroke value :title (tr "workspace.options.stroke-color") diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.scss index dae6ff306e..bfb581f5a6 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs index 17ee23f154..ad07f27f6d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.svg-attrs (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss index be8302e9f5..ed73954e38 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index 60b0c15c02..159d082478 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.text (:require-macros [app.main.style :as stl]) @@ -412,7 +412,7 @@ (select-keys txt/text-node-attrs))] (when (features/active-feature? @st/state "text-editor-wasm/v1") (st/emit! (dwt-v3/v3-update-text-editor-styles (first ids) attrs))) - (st/emit! (dwt/save-font updated-attrs) + (st/emit! (dwt/save-default-font updated-attrs) (dwt/update-all-attrs ids attrs))))) on-change @@ -498,6 +498,8 @@ (ts/schedule 0 #(some-> (mf/ref-val dropdown-ref) dom/focus!)))) [:section {:class (stl/css :element-set) + ;; Focusing these controls must not exit the v3 text editor (see `keep-editing-on-blur?`). + :data-keep-editing-on-blur true :aria-label (tr "workspace.options.text-options.text-section")} [:div {:class (stl/css :element-title)} [:> title-bar* {:collapsable true diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss index adc7da205a..4b39b0663c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "../../../sidebar/common/sidebar.scss" as sidebar; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs index 9ca3054996..509c4f492c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.token-typography-row (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.scss index 615e8379c5..32275f09b7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index 4dc3473644..6c4268cfce 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.typography (:require-macros [app.main.style :as stl]) @@ -12,7 +12,6 @@ [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.types.text :as txt] - [app.config :as cf] [app.main.constants :refer [max-input-length]] [app.main.data.common :as dcm] [app.main.data.fonts :as fts] @@ -91,11 +90,98 @@ (constantly nil))))) @loaded?)) -;; --- FEATURE: font preview (flag :font-preview) ------------------------------ -;; font-item-preview* and use-font-lazy-load are the whole feature. They are only -;; rendered/called behind the `:font-preview` flag check in font-item* below, so -;; their hooks never run when the flag is off. To remove the flag, inline -;; font-item-preview* into font-item* and drop the plain-name branch. +;; --- OPTICAL CENTERING OF SAMPLE TEXT -------------------------------------- + +;; Fonts with exaggerated vertical metrics (huge ascender/descender, small +;; caps) render their line box lower within a fixed-height row, so a plain +;; `align-items: center` leaves the visible glyphs sitting low. We measure the +;; font-wide vs glyph-ink bounding boxes once per font/sample and shift the +;; text by the computed offset so the visible glyphs are optically centered. +;; The offset is expressed in `em`, which makes it size-independent: the same +;; measurement corrects both the 16px `Ag` sample and the smaller font-name +;; labels in the font selector. + +(defonce ^:private optical-offset-cache (atom {})) + +(defn- optical-offset-key [family weight style text] + (dm/str family "|" weight "|" style "|" text)) + +(defn- optical-offset-em + "Vertical shift (in `em` units, i.e. relative to the font size) that centers + the ink of `text` within a single line box. + + For a centered line the shift reduces to the difference between the font-wide + and ink bounding boxes: + dy = ((ink-ascent - font-ascent) + (font-descent - ink-descent)) / 2. + Measuring at 16px and dividing the pixel shift by it yields the `em` value." + [family weight style text] + (when-some [{:keys [font-ascent font-descent ink-ascent ink-descent]} + (dom/measure-text-metrics family weight style text 16)] + (let [dy (/ (+ (- ink-ascent font-ascent) + (- font-descent ink-descent)) + 2) + em (/ dy 16)] + ;; Round to avoid float noise leaking into the transform string. + (/ (js/Math.round (* em 10000)) 10000)))) + +(defn- load-optical-offset + [font-id family weight style text] + (let [key (optical-offset-key family weight style text)] + (if-let [cached (get @optical-offset-cache key)] + (p/resolved cached) + (-> (fonts/ensure-loaded! font-id) + (p/then + (fn [_] + (let [em (or (optical-offset-em family weight style text) 0)] + (swap! optical-offset-cache assoc key em) + em))))))) + +(defn- use-optical-offset + "Lazily resolve the optical-centering offset (in `em`) for sample text in a + given font, measuring once per font/sample and caching it. Falls back to 0 + when the font isn't available or the metrics can't be measured." + [font-id family weight style text] + (let [offset* (mf/use-state 0)] + (mf/use-effect + (mf/deps font-id family weight style text) + (fn [] + (let [cancelled? (volatile! false) + key (optical-offset-key family weight style text)] + (if (contains? @optical-offset-cache key) + (reset! offset* (get @optical-offset-cache key)) + (let [task (tm/schedule-on-idle + (fn [] + (-> (load-optical-offset font-id family weight style text) + (p/then + (fn [em] + (when-not @cancelled? + (reset! offset* em)))))))] + (fn [] + (vreset! cancelled? true) + (tm/dispose! task))))) + nil)) + (deref offset*))) + +(defn- sample-container-style + "Inline style that applies the typography font to the (clipped, fixed-height) + sample container. Must be a real JS object (`#js`), not a ClojureScript map: + the `:style` value here is a runtime expression, not a literal recognized by + the hiccup macro, so it reaches React unconverted." + [typography] + #js {:fontFamily (:font-family typography) + :fontWeight (:font-weight typography) + :fontStyle (:font-style typography)}) + +(defn- sample-text-style + "Inline style that optically centers the sample glyphs. Must be applied to + the text node itself, not to the clipped container: a transform on an + `overflow: hidden` element moves its own clip region along with it, so it + would shift the whole box relative to the row instead of the glyphs inside it." + [em] + (when-not (zero? em) + #js {:transform (dm/str "translateY(" em "em)")})) + +;; --- FONT SELECTOR -------------------------------------------------------- (mf/defc font-item-preview* "Row content with previews: a vector preview from the shared sprite for catalog @@ -103,54 +189,69 @@ cover." {::mf/wrap [mf/memo]} [{:keys [font]}] - (let [font-id (:id font) + (let [font-id (get font :id) sprite (mf/deref fonts/preview-sprite) - in-sprite? (contains? (:ids sprite) font-id) - ;; Fallback is ONLY for custom fonts: ones the (ready) sprite doesn't - ;; cover. If the sprite isn't ready (loading/error) we show the plain name - ;; rather than runtime-loading the whole catalog. - fallback? (and (= :ready (:status sprite)) - (not in-sprite?)) - loaded? (use-font-lazy-load font-id fallback?)] + ;; The sprite is only referenceable once it's been attached to the DOM, + ;; so the `<use>` glyph is gated on `attached?`. Until then we show the + ;; plain name: no blank rows, and no per-font load storm either (see + ;; `fallback?` below). + attached? (pos? (:refs sprite)) + + ;; Fallback is ONLY for custom fonts: ones the (attached) sprite doesn't + ;; cover. If the sprite isn't ready (loading/error) or not yet attached, + ;; we show the plain name rather than runtime-loading the whole catalog. + in-sprite? (and attached? (contains? (:ids sprite) font-id)) + fallback? (and (= :ready (:status sprite)) attached? (not in-sprite?)) + loaded? (use-font-lazy-load font-id fallback?) + + ;; Optical centering for the fallback name (custom fonts the sprite + ;; doesn't cover): extreme vertical metrics would push the name low in + ;; the row, so shift it by the measured offset once the font is known. + ;; The label renders at `body-medium` (400/normal), which is the weight + ;; and style we measure against. + label-offset (use-optical-offset font-id + (:family font) + "400" + "normal" + (:name font))] (if in-sprite? ;; `fill: currentColor` (scss) makes the sprite glyph follow the row color. [:svg {:class (stl/css :font-item-preview) :role "img" :aria-label (:name font)} [:use {:href (dm/str "#" fonts/preview-sprite-prefix font-id)}]] - [:span {:class (stl/css :font-item-label) - :style (when loaded? - #js {:fontFamily (dm/str "\"" (:family font) "\", sans-serif")})} - (:name font)]))) + ;; The vertical correction goes on an INNER span, not on `.font-item-label` + ;; itself: that class carries its own `overflow: hidden` (from the + ;; text-ellipsis mixin, needed to truncate long font names), and a + ;; transform applied to a self-clipping element moves its clip region + ;; along with it — a no-op. The inner span has no overflow of its own, so + ;; the shift actually moves the ink within the outer's fixed clip area. + [:span {:class (stl/css :font-item-label)} + [:span {:style #js {:fontFamily (when loaded? + (dm/str "\"" (:family font) "\", sans-serif")) + :transform (when-not (zero? label-offset) + (dm/str "translateY(" label-offset "em)"))}} + (:name font)]]))) (mf/defc font-item* {::mf/wrap [mf/memo]} [{:keys [font is-current on-click style]}] (let [item-ref (mf/use-ref) - on-click (mf/use-fn (mf/deps font) #(on-click font)) - ;; FLAG :font-preview — gates the feature markup AND its row styling - ;; (.font-item-preview-on in the scss). Remove this and its two uses below. - preview? (contains? cf/flags :font-preview)] + on-click (mf/use-fn (mf/deps font) #(on-click font))] - (mf/use-effect - (mf/deps is-current) - (fn [] - (when is-current - (let [element (mf/ref-val item-ref)] - (when-not (dom/is-in-viewport? element) - (dom/scroll-into-view! element)))))) + (mf/with-effect [is-current] + (when is-current + (let [element (mf/ref-val item-ref)] + (when-not (dom/is-in-viewport? element) + (dom/scroll-into-view! element))))) [:div {:class (stl/css :font-wrapper) :style style :ref item-ref :on-click on-click} - [:div {:class (stl/css-case :font-item true - :font-item-preview-on preview? - :selected is-current)} - (if preview? - [:> font-item-preview* {:font font}] - [:span {:class (stl/css :font-item-label)} (:name font)]) + [:div {:class (stl/css-case :font-item true :selected is-current)} + [:> font-item-preview* {:font font}] (when is-current [:> icon* {:icon-id i/tick :size "s"}])]])) @@ -255,20 +356,28 @@ (let [key (events/listen js/document "keydown" on-key-down)] #(events/unlistenByKey key))) - ;; FLAG :font-preview — materialize the preview sprite into the DOM only while - ;; the picker is open (markup is prefetched on workspace load), removing it on - ;; close so its ~2000 nodes aren't kept around idle. Remove the flag clause to - ;; drop the feature. + ;; Materialize the preview sprite into the DOM only while the picker is open + ;; (markup is prefetched on workspace load), removing it on close so its + ;; ~2000 nodes aren't kept around idle. The attachment is deferred so the + ;; dropdown can paint first with plain names, then the sprite swaps in on the + ;; next tick. (mf/with-effect [sprite-status] - (when (and (contains? cf/flags :font-preview) - (= :ready sprite-status)) - (let [node (fonts/attach-preview-sprite!)] - #(fonts/detach-preview-sprite! node)))) + (when (= :ready sprite-status) + (let [node* (volatile! nil) + task (tm/schedule + (fn [] + (vreset! node* (fonts/attach-preview-sprite!))))] + (fn [] + (tm/dispose! task) + (when-some [n @node*] + (fonts/detach-preview-sprite! n)))))) (mf/with-effect [@selected] - (when-let [inst (mf/ref-val flist)] - (when-let [index (:index @selected)] - (.scrollToRow ^js inst index)))) + (let [node (mf/ref-val flist) + index (:index @selected)] + ;; This is nil safe operation, do nothing if node or index are + ;; invalid. + (dom/scroll-to-row node index))) (mf/with-effect [@selected] (on-select @selected)) @@ -279,11 +388,12 @@ (st/emit! (dsc/pop-shortcuts :typography)))) (mf/with-effect [] - (let [index (d/index-of-pred fonts #(= (:id %) (:id current-font))) - inst (mf/ref-val flist)] + (let [index (d/index-of-pred fonts #(= (:id %) (:id current-font))) + node (mf/ref-val flist)] (tm/schedule - #(let [offset (.getOffsetForRow ^js inst #js {:alignment "center" :index index})] - (.scrollToPosition ^js inst offset))))) + #(let [offset (.getOffsetForRow ^js node #js {:alignment "center" :index index})] + ;; Safe operaton, do nothing if node or offset has invalid values + (dom/scroll-to-position node offset))))) [:div {:class [(stl/css-case :font-selector true :fonts-on-modal (not full-size?))]} @@ -592,6 +702,11 @@ font-data (fonts/get-font-data (:font-id typography)) typography-id (:id typography) show-actions? (and is-asset? is-editable) + offset (use-optical-offset (:font-id typography) + (:font-family typography) + (:font-weight typography) + (:font-style typography) + "Ag") on-delete (mf/use-fn @@ -626,10 +741,9 @@ [:* [:div {:class (stl/css :font-name-wrapper)} [:div {:class (stl/css :typography-sample-input) - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:input {:class (stl/css :adv-typography-name) @@ -663,11 +777,9 @@ [:div {:class (stl/css :typography-info-wrapper)} [:div {:class (stl/css :typography-name-wrapper)} [:div {:class (stl/css :typography-sample) - - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:div {:class (stl/css :typography-name) :title (:name typography)} @@ -714,6 +826,11 @@ open? (deref open*) font-data (fonts/get-font-data (:font-id typography)) name-only? (= (:name typography) (:name font-data)) + offset (use-optical-offset (:font-id typography) + (:font-family typography) + (:font-weight typography) + (:font-style typography) + "Ag") on-name-blur (mf/use-fn @@ -771,10 +888,9 @@ [:div {:class (stl/css :font-name-wrapper)} [:div {:class (stl/css :typography-sample-input) - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:input {:class (stl/css :adv-typography-name) @@ -791,10 +907,9 @@ :on-context-menu on-context-menu} [:div {:class (stl/css :typography-sample) - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:div {:class (stl/css :name-block) :title (if name-only? diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss index c5b1a59d2f..bbfa19734e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; @@ -101,7 +101,7 @@ $font-preview-box-height: 28px; box-sizing: border-box; background-color: var(--font-name-wrapper-background-color); margin-block-end: var(--sp-s); - padding: var(--sp-s) 0 var(--sp-s) var(--sp-m); + padding-inline-start: var(--sp-m); &:focus-within { --font-name-wrapper-border-color: var(--color-accent-primary); @@ -119,6 +119,8 @@ $font-preview-box-height: 28px; inline-size: $sz-24; block-size: 100%; font-size: px2rem(16); + line-height: 1; + overflow: hidden; color: var(--color-foreground-primary); } @@ -171,8 +173,10 @@ $font-preview-box-height: 28px; align-items: center; min-inline-size: $sz-24; font-size: px2rem(16); + line-height: 1; block-size: $sz-32; padding: 0; + overflow: hidden; color: var(--color-foreground-primary); } @@ -388,20 +392,7 @@ $font-preview-box-height: 28px; &.selected { color: var(--color-foreground-primary); } -} -.font-item-label { - @include t.use-typography("body-small"); - @include text-ellipsis; - - flex-grow: 1; - min-inline-size: 0; -} - -// --- FLAG :font-preview row styling. Only applied when font-item* adds the -// .font-item-preview-on modifier; remove this whole block with the flag so rows -// render exactly as before. -.font-item-preview-on { // Center & clip so a previewed font's own metrics never grow/overflow the row. align-items: center; overflow: hidden; @@ -414,6 +405,14 @@ $font-preview-box-height: 28px; } } +.font-item-label { + @include t.use-typography("body-small"); + @include text-ellipsis; + + flex-grow: 1; + min-inline-size: 0; +} + // `currentColor` makes the glyph fill follow the row text color (theme + selected). .font-item-preview { flex-grow: 1; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs index 894a1d1e64..52b9687787 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs @@ -3,7 +3,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.variants-help-modal (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.scss index 74d6cef89c..00578743b5 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs index 95f236bd7c..d5869890db 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.page "Page options menu entries." diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/page.scss b/frontend/src/app/main/ui/workspace/sidebar/options/page.scss index c661729d21..9e64c4f0af 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/page.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/page.scss @@ -2,6 +2,6 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs index 3433048e26..fd070ef0cf 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.rows.color-row (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss index 3e49e343d7..078f8f4a0a 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs index 449ad67c83..fde57efcde 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.rows.shadow-row (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss index 4a9d8a72ca..a376138e7f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs index cdb971dbbf..1f84979d27 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.rows.stroke-row (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss index 227d229fd1..923c0aad7b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/bool.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/bool.cljs index 843b8ca560..36ba9b5eb7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/bool.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/bool.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.shapes.bool (:require diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/circle.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/circle.cljs index eced13447a..f596622f80 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/circle.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/circle.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.shapes.circle (:require diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/frame.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/frame.cljs index bff1030482..a9e131303b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/frame.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/frame.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.shapes.frame (:require diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.cljs index d0ae918bfe..901e712574 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.shapes.group (:require-macros [app.main.style :as stl]) @@ -93,20 +93,38 @@ [constraint-ids constraint-values] (get-attrs shapes objects :constraint) - [fill-ids fill-values fill-tokens] - (get-attrs shapes objects :fill) - [shadow-ids] (get-attrs shapes objects :shadow) [blur-ids blur-values] (get-attrs shapes objects :blur) + transform + (mf/deref refs/current-transform) + + ;; A transform cannot change the descendants read here. + descendant-attrs-ref + (mf/use-ref nil) + + descendant-attrs + (let [cached (mf/ref-val descendant-attrs-ref)] + (if (and (some? transform) (some? cached)) + cached + (let [attrs {:fill (get-attrs shapes objects :fill) + :stroke (get-attrs shapes objects :stroke) + :text (get-attrs shapes objects :text) + :colors (vals objects)}] + (mf/set-ref-val! descendant-attrs-ref attrs) + attrs))) + + [fill-ids fill-values fill-tokens] + (get descendant-attrs :fill) + [stroke-ids stroke-values stroke-tokens] - (get-attrs shapes objects :stroke) + (get descendant-attrs :stroke) [text-ids text-values text-tokens] - (get-attrs shapes objects :text) + (get descendant-attrs :text) [layout-item-ids layout-item-values] (get-attrs shapes objects :layout-item)] @@ -164,7 +182,7 @@ [:> color-selection-menu* {:type type - :shapes (vals objects) + :shapes (get descendant-attrs :colors) :file-id file-id :libraries libraries}] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.scss b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.scss index 77327e43d6..d994fad7b5 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs index f20dc82caa..a5ed1ce76b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.shapes.multiple (:require-macros [app.main.style :as stl]) @@ -268,6 +268,21 @@ applies (some of them ignore some attributes)" [shapes objects attr-group] (let [attrs (group->attrs attr-group) + + type->editable-attrs + (memoize (fn [type] + (if-let [editable? (get editable-attrs type)] + (filterv editable? attrs) + []))) + + type->nil-values + (memoize (fn [type] (into {} (map (fn [attr] [attr nil])) (type->editable-attrs type)))) + + type->token-attrs + (memoize (fn [type] + (into [] (comp (mapcat tt/shape-attr->token-attrs) (distinct)) + (type->editable-attrs type)))) + merge-attrs (fn [v1 v2] (cond @@ -289,24 +304,31 @@ (= existing new-val) acc :else (assoc acc t-attr :multiple)))) - merge-shape-attr - (fn [acc applied-tokens shape-attr] - "Merges all token attributes derived from a single shape attribute - into the accumulator map using `merge-attr`." - (let [token-attrs (tt/shape-attr->token-attrs shape-attr)] - (reduce #(merge-attr %1 applied-tokens %2) acc token-attrs))) + ;; Merging an empty `applied-tokens` into an accumulator that a previous + ;; empty merge already produced is a fixed point, so long runs of + ;; token-less shapes of the same type only pay for the first one. + stable-token-acc (volatile! nil) merge-token-values - (fn [acc shape-attrs applied-tokens] - "Merges token values across all shape attributes. - For each shape attribute, its corresponding token attributes are merged - into the accumulator." - (reduce #(merge-shape-attr %1 applied-tokens %2) acc shape-attrs)) + (fn [acc token-attrs applied-tokens] + "Merges token values across all token attributes derived from the shape's + editable attributes." + (let [no-tokens? (empty? applied-tokens) + stable (deref stable-token-acc)] + (if (and no-tokens? + (some? stable) + (identical? (nth stable 0) token-attrs) + (identical? (nth stable 1) acc)) + acc + (let [result (reduce #(merge-attr %1 applied-tokens %2) acc token-attrs)] + (when no-tokens? + (vreset! stable-token-acc [token-attrs result])) + result)))) extract-attrs (fn [[ids values token-acc] {:keys [id type applied-tokens] :as shape}] (let [read-mode (get-in type->read-mode [type attr-group]) - editable-attrs (filter (get editable-attrs (:type shape)) attrs)] + editable-attrs (type->editable-attrs type)] (case read-mode :ignore [ids values] @@ -315,14 +337,14 @@ (let [;; Get the editable attrs from the shape, ensuring that all attributes ;; are present, with value nil if they are not present in the shape. shape-values (merge - (into {} (map #(vector % nil)) editable-attrs) + (type->nil-values type) (cond (= attr-group :measure) (select-measure-keys shape) :else (select-keys shape editable-attrs))) shape-values (cond-> shape-values (= attr-group :layer) (update :hidden #(if (nil? %) false %))) - new-token-acc (merge-token-values token-acc editable-attrs applied-tokens)] + new-token-acc (merge-token-values token-acc (type->token-attrs type) applied-tokens)] [(conj ids id) (merge-attrs values shape-values) new-token-acc]) @@ -338,7 +360,7 @@ (merge-attrs shape-attrs) (merge-attrs content-attrs)) - new-token-acc (merge-token-values token-acc editable-attrs applied-tokens)] + new-token-acc (merge-token-values token-acc (type->token-attrs type) applied-tokens)] [(conj ids id) new-values new-token-acc]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.scss b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.scss index 77327e43d6..d994fad7b5 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/path.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/path.cljs index 05b8158544..7326666b57 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/path.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/path.cljs @@ -2,13 +2,18 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.shapes.path (:require [app.common.data.macros :as dm] + [app.common.files.helpers :as cfh] + [app.common.types.path :as cpath] [app.common.types.shape.layout :as ctl] + [app.main.data.workspace.path :as drp] + [app.main.data.workspace.path.helpers :as path.helpers] [app.main.refs :as refs] + [app.main.store :as st] [app.main.ui.workspace.sidebar.options.menus.blur :refer [blur-menu*]] [app.main.ui.workspace.sidebar.options.menus.constraints :refer [constraint-attrs constraints-menu*]] [app.main.ui.workspace.sidebar.options.menus.exports :refer [exports-menu* exports-attrs]] @@ -17,7 +22,7 @@ [app.main.ui.workspace.sidebar.options.menus.layer :refer [layer-attrs layer-menu*]] [app.main.ui.workspace.sidebar.options.menus.layout-container :refer [layout-container-flex-attrs layout-container-menu*]] [app.main.ui.workspace.sidebar.options.menus.layout-item :refer [layout-item-attrs layout-item-menu*]] - [app.main.ui.workspace.sidebar.options.menus.measures :refer [measure-attrs measures-menu*]] + [app.main.ui.workspace.sidebar.options.menus.measures :refer [measure-attrs measures-menu* node-position-menu*]] [app.main.ui.workspace.sidebar.options.menus.shadow :refer [shadow-menu*]] [app.main.ui.workspace.sidebar.options.menus.stroke :refer [stroke-attrs stroke-menu*]] [app.main.ui.workspace.sidebar.options.menus.svg-attrs :refer [svg-attrs-menu*]] @@ -144,3 +149,96 @@ :page-id page-id :file-id file-id}]])) +(mf/defc path-edition-options* + "Options shown while editing a path." + [{:keys [shape]}] + (let [id (dm/get-prop shape :id) + type (dm/get-prop shape :type) + ids (mf/with-memo [id] [id]) + shapes (mf/with-memo [shape] [shape]) + + applied-tokens + (get shape :applied-tokens) + + measure-values + (select-keys shape measure-attrs) + + stroke-values + (select-keys shape stroke-attrs) + + ;; Read coordinates from the live editing content. + edit-path (mf/deref refs/workspace-edit-path) + drawing (mf/deref refs/current-drawing-shape) + objects (mf/deref refs/workspace-page-objects) + selection (get-in edit-path [id :selection]) + modifiers (get-in edit-path [id :content-modifiers]) + + content + (mf/with-memo [drawing modifiers] + (when-let [base (get drawing :content)] + (cpath/apply-content-modifiers base modifiers))) + + ;; Show coordinates relative to the parent frame. + frame (cfh/get-parent-frame objects shape) + in-frame? (and (some? frame) (not (cfh/root? frame))) + ox (if in-frame? (dm/get-prop frame :x) 0) + oy (if in-frame? (dm/get-prop frame :y) 0) + + ;; Segments use selection bounds; nodes and handlers use their positions. + node-values + (mf/with-memo [content selection ox oy] + (when (and (some? content) (some? selection)) + (let [segments (get selection :segments) + handlers (get selection :handlers) + nodes (get selection :nodes)] + (cond + (seq segments) + (when-let [rect (path.helpers/selection-coordinate-rect + content selection)] + {:x (- (dm/get-prop rect :x) ox) + :y (- (dm/get-prop rect :y) oy)}) + + (or (seq nodes) (seq handlers)) + (let [positions (into (path.helpers/node-positions content (set nodes)) + (keep (fn [[i p]] (cpath/get-handler-point content i p))) + handlers)] + (when (seq positions) + (let [xs (into #{} (map #(- (:x %) ox)) positions) + ys (into #{} (map #(- (:y %) oy)) positions)] + {:x (if (= 1 (count xs)) (first xs) :multiple) + :y (if (= 1 (count ys)) (first ys) :multiple)}))))))) + + on-node-x-change + (mf/use-fn (mf/deps ox) + (fn [value] (when (some? value) (st/emit! (drp/set-selection-coordinate :x (+ value ox)))))) + + on-node-y-change + (mf/use-fn (mf/deps oy) + (fn [value] (when (some? value) (st/emit! (drp/set-selection-coordinate :y (+ value oy))))))] + + [:* + (when (some? node-values) + [:> node-position-menu* {:values node-values + :on-x-change on-node-x-change + :on-y-change on-node-y-change}]) + ;; Show read-only shape measures when no path element is selected. + (when (nil? node-values) + [:div {:style {:pointer-events "none" :opacity 0.6}} + [:> measures-menu* {:ids ids + :type type + :applied-tokens applied-tokens + :values measure-values + :shapes shapes}]]) + [:> fill/fill-menu* + {:ids ids + :type type + :values shape + :applied-tokens applied-tokens}] + [:> stroke-menu* {:ids ids + :type type + :show-caps true + :values stroke-values + :applied-tokens applied-tokens}] + [:> shadow-menu* {:ids ids :values (get shape :shadow)}] + [:> blur-menu* {:ids ids + :values (select-keys shape [:blur :background-blur])}]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/rect.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/rect.cljs index 2024ce5576..dd19747911 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/rect.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/rect.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.shapes.rect (:require diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/svg_raw.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/svg_raw.cljs index 42511bd2a7..a2fd1ed690 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/svg_raw.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/svg_raw.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.shapes.svg-raw (:require diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs index 9b3005ecea..3158f59be2 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.shapes.text (:require diff --git a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs index be454c585c..3b36a79318 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.shortcuts (:require-macros [app.main.style :as stl]) @@ -66,7 +66,12 @@ (ss/build-all-shortcuts workspace-shortcuts dashboard-shortcuts viewer-shortcuts) all-item-names (concat all-sc-names all-sub-names all-section-names) - match-any? (some #(matches-search % filter-term) all-item-names) + all-command-strings (->> (concat (vals workspace-shortcuts) + (vals dashboard-shortcuts) + (vals viewer-shortcuts)) + (map ss/shortcut->command-string)) + all-searchable-names (concat all-item-names all-command-strings) + match-any? (some #(matches-search % filter-term) all-searchable-names) manage-sections (fn [item] @@ -89,7 +94,8 @@ (fn [section term] (let [node-seq (tree-seq :children #(vals (:children %)) (get all-shortcuts section))] (reduce (fn [acc node] - (if (matches-search (:translation node) term) + (if (or (matches-search (:translation node) term) + (matches-search (ss/shortcut->command-string node) term)) (add-ids acc node) acc)) [] diff --git a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.scss b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.scss index a2b1771b30..07d7e60897 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs index bcb0771477..ac18b484d0 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.sitemap (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss b/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss index 4bfbe1c95d..f97f04123f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_borders.scss" as *; @@ -15,7 +15,8 @@ position: relative; display: flex; flex-direction: column; - flex: 1; + flex: 0 0 auto; + flex-shrink: 0; inline-size: 100%; block-size: var(--height, $sz-200); } diff --git a/frontend/src/app/main/ui/workspace/sidebar/versions.cljs b/frontend/src/app/main/ui/workspace/sidebar/versions.cljs index 13b5b10432..5f3be0c953 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/versions.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/versions.cljs @@ -2,7 +2,7 @@ ;; License v. 2.0. If a copy of the MPL was not distributed with this ;; file You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.versions (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/versions.scss b/frontend/src/app/main/ui/workspace/sidebar/versions.scss index 9605790813..ea96aa9792 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/versions.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/versions.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/workspace/text_palette.cljs b/frontend/src/app/main/ui/workspace/text_palette.cljs index abc3981090..b65d5842f7 100644 --- a/frontend/src/app/main/ui/workspace/text_palette.cljs +++ b/frontend/src/app/main/ui/workspace/text_palette.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.text-palette (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/text_palette.scss b/frontend/src/app/main/ui/workspace/text_palette.scss index c4c2a49ad8..61cf22e34b 100644 --- a/frontend/src/app/main/ui/workspace/text_palette.scss +++ b/frontend/src/app/main/ui/workspace/text_palette.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.cljs b/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.cljs index ae4622610d..cdc13170f2 100644 --- a/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.cljs +++ b/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.text-palette-ctx-menu (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss b/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss index b1aefa153c..9cf70b7e93 100644 --- a/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss +++ b/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/tokens/export.cljs b/frontend/src/app/main/ui/workspace/tokens/export.cljs index 8b6123bfff..55ecf194ea 100644 --- a/frontend/src/app/main/ui/workspace/tokens/export.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/export.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.export (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/export.scss b/frontend/src/app/main/ui/workspace/tokens/export.scss index 169c84c0a3..a6e1de4054 100644 --- a/frontend/src/app/main/ui/workspace/tokens/export.scss +++ b/frontend/src/app/main/ui/workspace/tokens/export.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs b/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs index c6e37138d2..b1c9ee0f09 100644 --- a/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.export.modal (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/export/modal.scss b/frontend/src/app/main/ui/workspace/tokens/export/modal.scss index 02edf4df25..d1ece74f9f 100644 --- a/frontend/src/app/main/ui/workspace/tokens/export/modal.scss +++ b/frontend/src/app/main/ui/workspace/tokens/export/modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/import.scss b/frontend/src/app/main/ui/workspace/tokens/import.scss index d6abf8e8f6..2ef8ed1b31 100644 --- a/frontend/src/app/main/ui/workspace/tokens/import.scss +++ b/frontend/src/app/main/ui/workspace/tokens/import.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/tokens/import/modal.cljs b/frontend/src/app/main/ui/workspace/tokens/import/modal.cljs index 8c5a886daa..359d9e47a5 100644 --- a/frontend/src/app/main/ui/workspace/tokens/import/modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/import/modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.import.modal (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/import/modal.scss b/frontend/src/app/main/ui/workspace/tokens/import/modal.scss index 440445f3b9..8244a29c46 100644 --- a/frontend/src/app/main/ui/workspace/tokens/import/modal.scss +++ b/frontend/src/app/main/ui/workspace/tokens/import/modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/import_from_library.cljs b/frontend/src/app/main/ui/workspace/tokens/import_from_library.cljs index 4481e1306d..0a9d910c40 100644 --- a/frontend/src/app/main/ui/workspace/tokens/import_from_library.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/import_from_library.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.import-from-library (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/import_from_library.scss b/frontend/src/app/main/ui/workspace/tokens/import_from_library.scss index b63eaf6648..b7b4ebc20a 100644 --- a/frontend/src/app/main/ui/workspace/tokens/import_from_library.scss +++ b/frontend/src/app/main/ui/workspace/tokens/import_from_library.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/workspace/tokens/management.scss b/frontend/src/app/main/ui/workspace/tokens/management.scss index a8303de162..d8edcc56b9 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index 455a8135bc..99af4965fd 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.context-menu (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.scss b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.scss index a50dc6e193..2709141353 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs index b208852c12..29243e1576 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.color (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs index a655d0756a..1e8d9a6198 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.controls.color-input (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs index 7c2e6b65dd..b3dbec0c94 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.controls.combobox (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.scss index 41cc87fd25..884f0d1497 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox_navigation.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox_navigation.cljs index be078ed2da..97b22a5a18 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox_navigation.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox_navigation.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.controls.combobox-navigation (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/floating_dropdown.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/floating_dropdown.cljs index cddb3b9eb4..0b4dd68554 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/floating_dropdown.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/floating_dropdown.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.controls.floating-dropdown (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs index e0bf1bb221..88e8346580 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.controls.fonts-combobox (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.scss index c1d132758c..d4027b3a1a 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs index aed66af134..8264836980 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.controls.input (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/select.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/select.cljs index 324a59328b..8e4a31ea7e 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/select.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/select.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.controls.select (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/token_parsing.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/token_parsing.cljs index b06486b9aa..cb628583cc 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/token_parsing.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/token_parsing.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.controls.token-parsing (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/font_family.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/font_family.cljs index 0168121331..21cfe89b78 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/font_family.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/font_family.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.font-family (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs index 31fc0b3c35..a817a69fe2 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.form-container (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs index efc0048f82..66b0bf93b7 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.generic-form (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.scss index 9d3e14762e..63f49e25b0 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.cljs index 1027215996..d46672ea31 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.modals (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.scss index d8ef365c71..8acb829575 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.scss index 16206e3ea2..426d4be102 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs index 41e0c2ce88..90fef836e7 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.shadow (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.scss index 690ced8025..ead3c104fe 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs index 62b9a63410..d23a9a1543 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.typography (:require-macros [app.main.style :as stl]) @@ -18,7 +18,6 @@ [app.main.ui.workspace.tokens.management.forms.generic-form :as generic] [app.main.ui.workspace.tokens.management.forms.validators :refer [check-coll-self-reference check-self-reference default-validate-token]] [app.util.i18n :refer [tr]] - [beicon.v2.core :as rx] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -43,11 +42,14 @@ (defn- validate-typography-token [{:keys [token-value] :as props}] (cond - ;; Entering form without a value - show no error just resolve nil - (nil? token-value) (rx/of nil) ;; Validate refrence string (cto/composite-token-reference? token-value) (default-validate-token props) - ;; Validate composite token + ;; Validate composite token. `token-value` may be nil when the form is + ;; submitted without any composite field filled in — normalize it to `{}` + ;; so `check-empty-typography-token` catches it and rejects the submit, + ;; instead of silently saving a token with a `nil` value (which later + ;; crashes token resolution: the tokens-studio StyleDictionary + ;; preprocessor assumes a typography token's value is never null). :else (-> props (update :token-value diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.scss index 6f384fe098..2aa3e9707c 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs index 020375c28e..4872c116d9 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.group diff --git a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.scss b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.scss index ef24d9e75c..9ce9830ef5 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs index e61c2fcbf3..d546bc9497 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.token-pill (:require-macros diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.scss b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.scss index 91a8d0301f..4580062688 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs index fa917bb0fc..c7528941bb 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.token-tree (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss index a9b99adde7..87ef7f2e4d 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/remapping_modal.cljs b/frontend/src/app/main/ui/workspace/tokens/remapping_modal.cljs index 10ce9adaf1..9c8d2bd33d 100644 --- a/frontend/src/app/main/ui/workspace/tokens/remapping_modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/remapping_modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.remapping-modal "Token remapping confirmation modal" diff --git a/frontend/src/app/main/ui/workspace/tokens/remapping_modal.scss b/frontend/src/app/main/ui/workspace/tokens/remapping_modal.scss index ead380916e..24a0fbc012 100644 --- a/frontend/src/app/main/ui/workspace/tokens/remapping_modal.scss +++ b/frontend/src/app/main/ui/workspace/tokens/remapping_modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/workspace/tokens/sets.cljs b/frontend/src/app/main/ui/workspace/tokens/sets.cljs index 460b508a18..2e3bc6478d 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sets.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.sets (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/sets.scss b/frontend/src/app/main/ui/workspace/tokens/sets.scss index 5fd685ed9f..8034c0bbd1 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets.scss +++ b/frontend/src/app/main/ui/workspace/tokens/sets.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.cljs index 5fc4e40c56..8c26b751de 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.sets.context-menu (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.scss b/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.scss index ff4cda241a..c09b856e2e 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.scss +++ b/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs b/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs index e835234ea9..4b778941b4 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.sets.lists diff --git a/frontend/src/app/main/ui/workspace/tokens/sets/lists.scss b/frontend/src/app/main/ui/workspace/tokens/sets/lists.scss index 5fd685ed9f..8034c0bbd1 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets/lists.scss +++ b/frontend/src/app/main/ui/workspace/tokens/sets/lists.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/tokens/settings.cljs b/frontend/src/app/main/ui/workspace/tokens/settings.cljs index 97d515fe1c..aba65dc7a8 100644 --- a/frontend/src/app/main/ui/workspace/tokens/settings.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/settings.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.settings (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/settings/menu.cljs b/frontend/src/app/main/ui/workspace/tokens/settings/menu.cljs index 3acd1439dd..4833ecb59c 100644 --- a/frontend/src/app/main/ui/workspace/tokens/settings/menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/settings/menu.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.settings.menu (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/settings/menu.scss b/frontend/src/app/main/ui/workspace/tokens/settings/menu.scss index 377744453e..60ff17126b 100644 --- a/frontend/src/app/main/ui/workspace/tokens/settings/menu.scss +++ b/frontend/src/app/main/ui/workspace/tokens/settings/menu.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/spacing.scss" as *; @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/tokens/sidebar.cljs b/frontend/src/app/main/ui/workspace/tokens/sidebar.cljs index dfbc3815d1..4d2f77863f 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sidebar.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sidebar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.sidebar (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/sidebar.scss b/frontend/src/app/main/ui/workspace/tokens/sidebar.scss index 9c11e0178d..7fcbb8a2e7 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sidebar.scss +++ b/frontend/src/app/main/ui/workspace/tokens/sidebar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/spacing.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/themes.cljs b/frontend/src/app/main/ui/workspace/tokens/themes.cljs index ce9a960b5f..7661287472 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.themes (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/themes.scss b/frontend/src/app/main/ui/workspace/tokens/themes.scss index 19e1de91a0..326596b479 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes.scss +++ b/frontend/src/app/main/ui/workspace/tokens/themes.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/_sizes" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs index 5d583a22be..1e9e8d1ce7 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.themes.create-modal (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.scss b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.scss index a90a1e4c6f..aaa2c0f199 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.scss +++ b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs index 92f5f6ad03..65c50373c8 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.themes.theme-selector (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.scss b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.scss index 6715ee707c..3fdaa320ca 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.scss +++ b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.cljs b/frontend/src/app/main/ui/workspace/top_toolbar.cljs index 2590122475..f3d843000d 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.cljs +++ b/frontend/src/app/main/ui/workspace/top_toolbar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.top-toolbar (:require-macros [app.main.style :as stl]) @@ -17,6 +17,7 @@ [app.main.data.workspace.drawing.common :as dwdc] [app.main.data.workspace.mcp :as mcp] [app.main.data.workspace.media :as dwm] + [app.main.data.workspace.path.state :as pst] [app.main.data.workspace.shortcuts :as sc] [app.main.features :as features] [app.main.refs :as refs] @@ -37,16 +38,16 @@ (def ^:private toolbar-hidden-ref (l/derived (fn [state] - (let [visibility (get state :hide-toolbar) - path-edit-state (get state :edit-path) - selected (get state :selected) - edition (get state :edition) + (let [visibility (get-in state [:workspace-local :hide-toolbar]) + selected (get-in state [:workspace-local :selected]) is-single (= (count selected) 1) - is-path-editing (and is-single (some? (get path-edit-state edition)))] + ;; The path edition bar replaces this toolbar. + is-path-editing (and is-single (pst/editing? state)) + is-path-drawing (pst/drawing? state)] - (if is-path-editing true visibility))) - refs/workspace-local)) + (if (or is-path-editing is-path-drawing) true visibility))) + st/state)) (def grouped-tools {:shapes {:default-tool :rect @@ -149,16 +150,26 @@ on-main-key-down (mf/use-fn + (mf/deps open) (fn [event] (cond - (kbd/space? event) + (and open (kbd/esc? event)) + (reset! open* false) + + (or (kbd/enter? event) (kbd/space? event)) + (do + (dom/prevent-default event) + (if open + (reset! open* false) + (do + (cancel-timer! close-timer*) + (reset! open* true)))) + + (kbd/down-arrow? event) (do (dom/prevent-default event) (cancel-timer! close-timer*) - (reset! open* true)) - - (and open (kbd/esc? event)) - (reset! open* false)))) + (reset! open* true))))) on-flyout-key-down (mf/use-fn @@ -218,7 +229,10 @@ :aria-expanded open :has-tooltip false :icon default-icon - :on-click on-select-tool + :on-click (fn [event] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (on-select-tool event)) :on-key-down on-main-key-down :data-tool (name default-tool)}] diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.scss b/frontend/src/app/main/ui/workspace/top_toolbar.scss index 0db0002fbf..23581afc99 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.scss +++ b/frontend/src/app/main/ui/workspace/top_toolbar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 5f71417631..1e2070c965 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport (:require-macros [app.main.style :as stl]) @@ -13,7 +13,6 @@ [app.common.geom.shapes :as gsh] [app.common.types.color :as clr] [app.common.types.component :as ctk] - [app.common.types.path :as path] [app.common.types.shape :as cts] [app.common.types.shape-tree :as ctt] [app.common.types.shape.layout :as ctl] @@ -46,6 +45,7 @@ [app.main.ui.workspace.viewport.hooks :as hooks] [app.main.ui.workspace.viewport.interactions :as interactions] [app.main.ui.workspace.viewport.outline :as outline] + [app.main.ui.workspace.viewport.path-state :as path-state] [app.main.ui.workspace.viewport.pixel-overlay :as pixel-overlay] [app.main.ui.workspace.viewport.presence :as presence] [app.main.ui.workspace.viewport.rulers :as rulers] @@ -53,8 +53,7 @@ [app.main.ui.workspace.viewport.selection :as selection] [app.main.ui.workspace.viewport.snap-distances :as snap-distances] [app.main.ui.workspace.viewport.snap-points :as snap-points] - [app.main.ui.workspace.viewport.top-bar :refer [grid-edition-bar* - path-edition-bar* + [app.main.ui.workspace.viewport.top-bar :refer [edition-bars* view-only-bar*]] [app.main.ui.workspace.viewport.utils :as utils] [app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]] @@ -177,21 +176,22 @@ selected-frame (when (= (count selected-frames) 1) (get base-objects (first selected-frames))) - edit-path-state (get edit-path edition) - edit-path-mode (get edit-path-state :edit-mode) + {:keys [edit-state + editing? + drawing? + editing-shape + bar-state + bar-shape + drawing-shape]} + (mf/with-memo [edit-path edition drawing-tool drawing-obj base-objects] + (path-state/derive-path-state edit-path edition drawing-tool drawing-obj base-objects)) - path-editing? (some? edit-path-state) - path-drawing? (or (= edit-path-mode :draw) - (and (= :path (get drawing-obj :type)) - (not= :curve drawing-tool))) - - editing-shape (when edition - (get base-objects edition)) - - editing-shape (mf/with-memo [editing-shape path-editing? base-objects] - (if path-editing? - (path/convert-to-path editing-shape base-objects) - editing-shape)) + edit-path-state edit-state + path-editing? editing? + path-drawing? drawing? + path-bar-state bar-state + path-bar-shape bar-shape + draw-area-shape drawing-shape create-comment? (= :comments drawing-tool) @@ -255,8 +255,14 @@ (seq selected)) show-snap-points? (and (or (contains? layout :dynamic-alignment) (contains? layout :snap-guides)) - (or drawing-obj transform)) - show-selrect? (and selrect (empty? drawing) (not text-editing?)) + (or drawing-obj transform) + (not path-editing?)) + + render-objects (mf/with-memo [base-objects path-editing? edition] + (cond-> base-objects + path-editing? + (assoc-in [edition :hidden] true))) + show-selrect? (and selrect (or (empty? drawing) path-editing?) (not text-editing?)) show-measures? (and (not transform) (not path-editing?) (or show-distances? mode-inspect? read-only?)) @@ -311,7 +317,7 @@ (hooks/setup-dom-events zoom disable-paste-ref in-viewport-ref read-only? drawing-tool path-drawing?) (hooks/setup-viewport-size vport viewport-ref) - (hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?) + (hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? (get path-bar-state :drag-cursor) z? read-only?) (hooks/setup-keyboard alt? mod? space? z? shift?) (hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only? transform) @@ -332,15 +338,14 @@ (when-not hide-ui? [:> top-toolbar* {:layout layout}]) - (when (and ^boolean path-editing? - ^boolean single-select?) - [:> path-edition-bar* {:shape editing-shape - :edit-path-state edit-path-state - :layout layout}]) - - (when (and ^boolean grid-editing? - ^boolean single-select?) - [:> grid-edition-bar* {:shape editing-shape}])]) + [:> edition-bars* {:layout layout + :path-editing path-editing? + :path-drawing path-drawing? + :path-state path-bar-state + :path-shape path-bar-shape + :grid-editing grid-editing? + :grid-shape editing-shape + :single-select single-select?}]]) [:div {:class (stl/css :viewport-overlays)} ;; The behaviour inside a foreign object is a bit different that in plain HTML so we wrap @@ -363,7 +368,8 @@ :page-id page-id :file-id file-id :vport vport - :zoom zoom}]) + :zoom zoom + :show-rulers show-rulers?}]) (when picking-color? [:> pixel-overlay/pixel-overlay* {:vport vport @@ -411,7 +417,7 @@ [:& (mf/provider use/include-metadata-ctx) {:value (dbg/enabled? :show-export-metadata)} ;; Render root shape [:& shapes/root-shape {:key (str page-id) - :objects base-objects + :objects render-objects :active-frames @active-frames ;; disable thumbnails when previewing a version :disable-thumbnails (some? preview-id)}]]]] @@ -454,8 +460,8 @@ [:& editor-v2/text-editor {:shape editing-shape :canvas-ref canvas-ref :modifiers modifiers}] - [:> editor-v1/text-editor-svg* {:shape editing-shape - :modifiers modifiers}])) + [:& editor-v1/text-editor-svg {:shape editing-shape + :modifiers modifiers}])) (when show-frame-outline? (let [outlined-frame-id @@ -594,7 +600,7 @@ (when (and ^boolean show-draw-area? ^boolean (cts/shape? drawing-obj)) [:> drawarea/draw-area* - {:shape drawing-obj + {:shape draw-area-shape :zoom zoom :tool drawing-tool}]) diff --git a/frontend/src/app/main/ui/workspace/viewport.scss b/frontend/src/app/main/ui/workspace/viewport.scss index 28940b67a7..bc94d58fd9 100644 --- a/frontend/src/app/main/ui/workspace/viewport.scss +++ b/frontend/src/app/main/ui/workspace/viewport.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs index 3803577d45..2763174079 100644 --- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.actions (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/comments.cljs b/frontend/src/app/main/ui/workspace/viewport/comments.cljs index 288b537f1b..d17eed3ef2 100644 --- a/frontend/src/app/main/ui/workspace/viewport/comments.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/comments.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.comments (:require-macros [app.main.style :as stl]) @@ -13,6 +13,7 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.comments :as cmt] + [app.main.ui.workspace.viewport.rulers :as rulers] [rumext.v2 :as mf])) ;; Pin transform for the bubble's frame so it follows the frame during a drag, @@ -70,7 +71,7 @@ (mf/defc comments-layer* {::mf/wrap [mf/memo]} - [{:keys [vbox vport zoom file-id page-id]}] + [{:keys [vbox vport zoom file-id page-id show-rulers]}] (let [vbox-x (dm/get-prop vbox :x) vbox-y (dm/get-prop vbox :y) vport-w (dm/get-prop vport :width) @@ -114,7 +115,15 @@ {:id "comments" :class (stl/css :workspace-comments-container) :style {:width (dm/str vport-w "px") - :height (dm/str vport-h "px")}} + :height (dm/str vport-h "px") + ;; This layer sits above the canvas, so without clipping the + ;; bubbles paint over the rulers as they pan past them. Keep + ;; them out of the ruler bars, like `clip-handlers` does for + ;; the selection handlers. + :clip-path (when show-rulers + (dm/fmt "inset(%px 0 0 %px)" + rulers/ruler-area-size + rulers/ruler-area-size))}} [:div {:class (stl/css :threads) :style {:transform (dm/fmt "translate(%px, %px)" pos-x pos-y)}} diff --git a/frontend/src/app/main/ui/workspace/viewport/comments.scss b/frontend/src/app/main/ui/workspace/viewport/comments.scss index 2c672cab4b..703b239d53 100644 --- a/frontend/src/app/main/ui/workspace/viewport/comments.scss +++ b/frontend/src/app/main/ui/workspace/viewport/comments.scss @@ -2,16 +2,13 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL - -@use "refactor/common-refactor.scss" as deprecated; +// Copyright (c) KALEIDOS SUBSIDIARY SL .workspace-comments-container { - width: 100%; - height: 100%; + inline-size: 100%; + block-size: 100%; grid-column: 1 / span 2; grid-row: 1 / span 2; - z-index: 1000; pointer-events: none; overflow: hidden; user-select: text; diff --git a/frontend/src/app/main/ui/workspace/viewport/debug.cljs b/frontend/src/app/main/ui/workspace/viewport/debug.cljs index 123c0ad387..5a89a97e0a 100644 --- a/frontend/src/app/main/ui/workspace/viewport/debug.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/debug.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.debug (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/drawarea.cljs b/frontend/src/app/main/ui/workspace/viewport/drawarea.cljs index 9bcebcafc3..910688658f 100644 --- a/frontend/src/app/main/ui/workspace/viewport/drawarea.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/drawarea.cljs @@ -2,13 +2,14 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.drawarea "Drawing components." (:require [app.common.data.macros :as dm] [app.common.math :as mth] + [app.common.types.path :as path] [app.main.refs :as refs] [app.main.ui.shapes.path :refer [path-shape]] [app.main.ui.workspace.shapes :as shapes] @@ -20,6 +21,27 @@ (let [get-fn #(dm/get-in % [:edit-path id])] (l/derived get-fn refs/workspace-local))) +(def ^:private edit-fill-opacity + "Fill opacity used while editing a path." + 0.8) + +(def ^:private synced-edit-attrs + "Visual attributes copied into the live editing shape." + [:strokes :shadow :blur :background-blur :opacity :blend-mode]) + +(defn- dim-fills + [fills] + (mapv (fn [fill] + (update fill :fill-opacity #(* (or % 1) edit-fill-opacity))) + fills)) + +(defn path-edit-shape + "Builds the path shape rendered during editing." + [drawing-obj stored] + (-> (cond-> (merge drawing-obj (select-keys stored synced-edit-attrs)) + (seq (:fills stored)) (assoc :fills (:fills stored))) + (update :fills dim-fills))) + (mf/defc generic-draw-area* {::mf/private true} [{:keys [shape zoom]}] @@ -55,12 +77,36 @@ (mf/defc draw-area* [{:keys [shape zoom tool] :as props}] - [:g.draw-area - [:g {:style {:pointer-events "none"}} - [:& shapes/shape-wrapper {:shape shape}]] + (let [shape-id + (dm/get-prop shape :id) - (case tool - :path [:> path-draw-area* props] - :curve [:& path-shape {:shape shape :zoom zoom}] - #_:default [:> generic-draw-area* props])]) + edit-path-ref + (mf/with-memo [shape-id] + (make-edit-path-ref shape-id)) + ;; Keep command indices unchanged while applying drag modifiers. + dragging? + (some? (:content-modifiers (mf/deref edit-path-ref))) + + ;; Close rendered subpaths while keeping editor content untouched. + render-shape + (mf/with-memo [shape dragging?] + (if (and (= :path (dm/get-prop shape :type)) (not dragging?)) + (update shape :content #(-> % path/close-subpaths path/close-loops)) + shape))] + [:g.draw-area + [:g {:style {:pointer-events "none"}} + [:& shapes/shape-wrapper {:shape render-shape}]] + + (cond + (= tool :path) + [:> path-draw-area* props] + + (= tool :curve) + [:& path-shape {:shape shape :zoom zoom}] + + (= (:type shape) :path) + nil + + :else + [:> generic-draw-area* props])])) diff --git a/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs b/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs index 53b9eefe56..931fb6e4df 100644 --- a/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.frame-grid (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/gradients.cljs b/frontend/src/app/main/ui/workspace/viewport/gradients.cljs index ec2f9b586c..f81a15847f 100644 --- a/frontend/src/app/main/ui/workspace/viewport/gradients.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/gradients.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.gradients "Gradients handlers and renders" diff --git a/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.cljs b/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.cljs index b851d09c4e..d8e1314dc4 100644 --- a/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.grid-layout-editor (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss b/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss index 26d473689c..008f4e8b0d 100644 --- a/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss +++ b/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/z-index.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/viewport/guides.cljs b/frontend/src/app/main/ui/workspace/viewport/guides.cljs index 07cc05f357..69e0df5a31 100644 --- a/frontend/src/app/main/ui/workspace/viewport/guides.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/guides.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.guides (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs index d820f01aa2..b7b3ff9292 100644 --- a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.hooks (:require @@ -25,6 +25,7 @@ [app.main.features :as features] [app.main.store :as st] [app.main.streams :as ms] + [app.main.ui.css-cursors :as cur] [app.main.ui.hooks :as hooks] [app.main.ui.workspace.shapes.frame.dynamic-modifiers :as sfd] [app.main.ui.workspace.viewport.actions :as actions] @@ -91,9 +92,9 @@ (when (not= size vport) (st/emit! (dw/initialize-viewport (dom/get-client-size prnt))))))) -(defn setup-cursor [cursor alt? mod? space? panning drawing-tool drawing-path? path-editing? z? workspace-read-only?] +(defn setup-cursor [cursor alt? mod? space? panning drawing-tool drawing-path? path-editing? path-drag-cursor z? workspace-read-only?] (mf/use-effect - (mf/deps @cursor @alt? @mod? @space? panning drawing-tool drawing-path? path-editing? z? workspace-read-only?) + (mf/deps @cursor @alt? @mod? @space? panning drawing-tool drawing-path? path-editing? path-drag-cursor z? workspace-read-only?) (fn [] (let [show-pen? (or (= drawing-tool :path) (and drawing-path? @@ -108,18 +109,20 @@ (cond (and @mod? @space?) (utils/get-cursor :zoom) (or panning @space?) (utils/get-cursor :hand) + ;; Keep the drag cursor across the viewport. + (some? path-drag-cursor) (cur/get-static path-drag-cursor) (= drawing-tool :comments) (utils/get-cursor :comments) (= drawing-tool :frame) (utils/get-cursor :create-artboard) (= drawing-tool :rect) (utils/get-cursor :create-rectangle) (= drawing-tool :circle) (utils/get-cursor :create-ellipse) (and show-zoom? (not @alt?)) (utils/get-cursor :zoom-in) (and show-zoom? @alt?) (utils/get-cursor :zoom-out) - show-pen? (utils/get-cursor :pen) + show-pen? (utils/get-cursor :draw-path) (= drawing-tool :curve) (utils/get-cursor :pencil) drawing-tool (utils/get-cursor :create-shape) + path-editing? (utils/get-cursor :edit-path) (and @alt? - (not path-editing?) (not workspace-read-only?)) (utils/get-cursor :duplicate) :else (utils/get-cursor :pointer-inner))] diff --git a/frontend/src/app/main/ui/workspace/viewport/interactions.cljs b/frontend/src/app/main/ui/workspace/viewport/interactions.cljs index 6ffa2d32de..93b7e7f111 100644 --- a/frontend/src/app/main/ui/workspace/viewport/interactions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/interactions.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.interactions "Visually show shape interactions in workspace" diff --git a/frontend/src/app/main/ui/workspace/viewport/outline.cljs b/frontend/src/app/main/ui/workspace/viewport/outline.cljs index 725065d2f8..35a68c3b07 100644 --- a/frontend/src/app/main/ui/workspace/viewport/outline.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/outline.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.outline (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/path_actions.cljs b/frontend/src/app/main/ui/workspace/viewport/path_actions.cljs index fc70c97de6..2ec5a82d17 100644 --- a/frontend/src/app/main/ui/workspace/viewport/path_actions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/path_actions.cljs @@ -2,17 +2,18 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.path-actions (:require-macros [app.main.style :as stl]) (:require - [app.common.types.path.segment :as path.segm] [app.main.data.workspace.path :as drp] + [app.main.data.workspace.path.helpers :as path.helpers] [app.main.data.workspace.path.shortcuts :as sc] [app.main.store :as st] [app.main.ui.icons :as deprecated-icon] [app.util.i18n :as i18n :refer [tr]] + [app.util.timers :as ts] [rumext.v2 :as mf])) (def ^:private pentool-icon @@ -21,12 +22,6 @@ (def ^:private move-icon (deprecated-icon/icon-xref :move (stl/css :move-icon :pathbar-icon))) -(def ^:private add-icon - (deprecated-icon/icon-xref :add (stl/css :add-icon :pathbar-icon))) - -(def ^:private remove-icon - (deprecated-icon/icon-xref :remove (stl/css :remove :pathbar-icon))) - (def ^:private merge-nodes-icon (deprecated-icon/icon-xref :merge-nodes (stl/css :merge-nodes-icon :pathbar-icon))) @@ -42,40 +37,152 @@ (def ^:private to-curve-icon (deprecated-icon/icon-xref :to-curve (stl/css :to-curve-icon :pathbar-icon))) -(def ^:private snap-nodes-icon - (deprecated-icon/icon-xref :snap-nodes (stl/css :snap-nodes-icon :pathbar-icon))) +(def ^:private snap-icon + (deprecated-icon/icon-xref :snap (stl/css :snap-icon :pathbar-icon))) -(defn check-enabled [content selected-points] - (when content - (let [segments (path.segm/get-segments-with-points content selected-points) - num-segments (count segments) - num-points (count selected-points) - points-selected? (seq selected-points) - segments-selected? (seq segments) - ;; max segments for n points is (n × (n -1)) / 2 - max-segments (-> num-points - (* (- num-points 1)) - (/ 2)) - is-curve? (some #(path.segm/is-curve? content %) selected-points)] +;; Handler behavior icons: mirror, aligned, and independent. +(def ^:private handler-mirror-icon + (deprecated-icon/icon-xref :handlers-equal (stl/css :pathbar-icon))) - {:make-corner (and points-selected? is-curve?) - :make-curve (and points-selected? (not is-curve?)) - :add-node segments-selected? - :remove-node points-selected? - :merge-nodes segments-selected? - :join-nodes (and points-selected? (>= num-points 2) (< num-segments max-segments)) - :separate-nodes segments-selected?}))) +(def ^:private handler-aligned-icon + (deprecated-icon/icon-xref :handlers-mirror (stl/css :pathbar-icon))) + +(def ^:private handler-independent-icon + (deprecated-icon/icon-xref :handlers-independent (stl/css :pathbar-icon))) + +(defn- handler-type-icon [type] + (case type + :mirror handler-mirror-icon + :aligned handler-aligned-icon + :independent handler-independent-icon + ;; Use the independent icon for mixed selections. + :mixed handler-independent-icon + handler-independent-icon)) + +(defn toolbar-group-visibility + [structural-visible? shape-visible? handler-visible?] + (let [shape-handler-visible? (or shape-visible? handler-visible?)] + {:shape-handler-visible? shape-handler-visible? + :node-groups-separator-visible? (and structural-visible? shape-handler-visible?) + :snap-separator-visible? (or structural-visible? shape-handler-visible?)})) + +(mf/defc topbar-button* + "A path node action button." + {::mf/private true} + [{:keys [title on-click icon]}] + [:button {:class (stl/css :topbar-btn) + :title title + :on-click on-click} + icon]) + +(defn- cancel-timer! + [timer-ref*] + (when-let [timer (mf/ref-val timer-ref*)] + (ts/dispose! timer) + (mf/set-ref-val! timer-ref* nil))) + +(mf/defc handler-type-menu* + "Sets the handler behavior of selected nodes." + {::mf/private true} + [{:keys [active-type on-select]}] + (let [open* (mf/use-state false) + open? (deref open*) + + open-timer* (mf/use-ref nil) + close-timer* (mf/use-ref nil) + + select + (mf/use-fn + (mf/deps on-select) + (fn [type] + (reset! open* false) + (on-select type))) + + on-trigger-click + (mf/use-fn + (mf/deps select active-type) + (fn [] + (case (path.helpers/handler-trigger-action active-type) + :open + (do + (cancel-timer! close-timer*) + (cancel-timer! open-timer*) + (reset! open* true)) + + :select + (select active-type)))) + + on-display-menu + (mf/use-fn + (fn [] + (cancel-timer! close-timer*) + (cancel-timer! open-timer*) + (mf/set-ref-val! + open-timer* + (ts/schedule 350 + #(do + (reset! open* true) + (mf/set-ref-val! open-timer* nil)))))) + + on-hide-menu + (mf/use-fn + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (mf/set-ref-val! + close-timer* + (ts/schedule 350 + #(do + (reset! open* false) + (mf/set-ref-val! close-timer* nil))))))] + + (mf/with-effect [] + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*))) + + [:div {:class (stl/css :handler-menu) + :on-pointer-enter on-display-menu + :on-pointer-leave on-hide-menu} + [:button {:class (stl/css :topbar-btn :handler-trigger) + :title (tr "workspace.path.actions.handler-type") + :aria-haspopup true + :aria-expanded open? + :on-click on-trigger-click} + (handler-type-icon active-type) + [:svg {:view-box "0 0 6 6" + :aria-hidden true + :class (stl/css :flyout-indicator)} + [:path {:d "M4,2 L4,3.15 C4,3.62 3.62,4 3.15,4 L2,4" + :stroke-linecap "round"}]]] + [:div {:class (stl/css-case :handler-popover true :open open?) + :data-dont-clear-path true} + [:button {:class (stl/css-case :is-toggled (= active-type :mirror) :topbar-btn true) + :title (tr "workspace.path.actions.handler-mirror") + :on-click #(select :mirror)} + handler-mirror-icon] + [:button {:class (stl/css-case :is-toggled (= active-type :aligned) :topbar-btn true) + :title (tr "workspace.path.actions.handler-aligned") + :on-click #(select :aligned)} + handler-aligned-icon] + [:button {:class (stl/css-case :is-toggled (= active-type :independent) :topbar-btn true) + :title (tr "workspace.path.actions.handler-independent") + :on-click #(select :independent)} + handler-independent-icon]]])) (mf/defc path-actions* [{:keys [shape state]}] - (let [{:keys [edit-mode selected-points snap-toggled]} state + (let [{:keys [edit-mode selection snap-toggled]} state content (:content shape) + ;; Include segment endpoints in node actions. + selected-nodes (path.helpers/selected-node-indices content selection) + enabled-buttons (mf/use-memo - (mf/deps content selected-points) - #(check-enabled content selected-points)) + (mf/deps content selection) + #(path.helpers/check-enabled content selected-nodes)) on-select-draw-mode (mf/use-fn @@ -87,20 +194,6 @@ (fn [_] (st/emit! (drp/change-edit-mode :move)))) - on-add-node - (mf/use-fn - (mf/deps (:add-node enabled-buttons)) - (fn [_] - (when (:add-node enabled-buttons) - (st/emit! (drp/add-node))))) - - on-remove-node - (mf/use-fn - (mf/deps (:remove-node enabled-buttons)) - (fn [_] - (when (:remove-node enabled-buttons) - (st/emit! (drp/remove-node))))) - on-merge-nodes (mf/use-fn (mf/deps (:merge-nodes enabled-buttons)) @@ -139,81 +232,93 @@ on-toggle-snap (mf/use-fn (fn [_] - (st/emit! (drp/toggle-snap))))] + (st/emit! (drp/toggle-snap)))) + + ;; Show node actions only when they apply. + structural-visible? (or (:merge-nodes enabled-buttons) + (:join-nodes enabled-buttons) + (:separate-nodes enabled-buttons)) + shape-visible? (or (:make-corner enabled-buttons) + (:make-curve enabled-buttons)) + + ;; Resolve selected handlers to their curve nodes. + handler-nodes (path.helpers/handler-target-nodes content selection) + handler-state (path.helpers/handler-selection-state + content (:handler-types state) handler-nodes) + active-handler-type (:active-type handler-state) + handler-visible? (and (= edit-mode :move) (seq (:nodes handler-state))) + + group-visibility + (toolbar-group-visibility structural-visible? shape-visible? handler-visible?) + + node-groups-separator-visible? + (:node-groups-separator-visible? group-visibility) + + middle-visible? + (:snap-separator-visible? group-visibility) + + on-set-handler-type + (mf/use-fn + (fn [type] + (st/emit! (drp/set-handler-type type))))] [:div {:class (stl/css :sub-actions) :data-dont-clear-path true} + ;; Mode: draw / move (always visible) [:div {:class (stl/css :sub-actions-group)} - - ;; Draw Mode - [:button {:class (stl/css-case :is-toggled (= edit-mode :draw) - :topbar-btn true) + [:button {:class (stl/css-case :is-toggled (= edit-mode :draw) :topbar-btn true) :title (tr "workspace.path.actions.draw-nodes" (sc/get-tooltip :draw-nodes)) :on-click on-select-draw-mode} pentool-icon] - - ;; Edit mode - [:button {:class (stl/css-case :is-toggled (= edit-mode :move) - :topbar-btn true) + [:button {:class (stl/css-case :is-toggled (= edit-mode :move) :topbar-btn true) :title (tr "workspace.path.actions.move-nodes" (sc/get-tooltip :move-nodes)) :on-click on-select-edit-mode} move-icon]] - [:div {:class (stl/css :sub-actions-group)} - ;; Add Node - [:button {:disabled (not (:add-node enabled-buttons)) - :class (stl/css :topbar-btn) - :title (tr "workspace.path.actions.add-node" (sc/get-tooltip :add-node)) - :on-click on-add-node} - add-icon] + [:div {:class (stl/css :separator)}] - ;; Remove node - [:button {:disabled (not (:remove-node enabled-buttons)) - :class (stl/css :topbar-btn) - :title (tr "workspace.path.actions.delete-node" (sc/get-tooltip :delete-node)) - :on-click on-remove-node} - remove-icon]] + ;; Structural node ops: merge / join / separate + (when structural-visible? + [:div {:class (stl/css :sub-actions-group)} + (when (:merge-nodes enabled-buttons) + [:> topbar-button* {:title (tr "workspace.path.actions.merge-nodes" (sc/get-tooltip :merge-nodes)) + :on-click on-merge-nodes + :icon merge-nodes-icon}]) + (when (:join-nodes enabled-buttons) + [:> topbar-button* {:title (tr "workspace.path.actions.join-nodes" (sc/get-tooltip :join-nodes)) + :on-click on-join-nodes + :icon join-nodes-icon}]) + (when (:separate-nodes enabled-buttons) + [:> topbar-button* {:title (tr "workspace.path.actions.separate-nodes" (sc/get-tooltip :separate-nodes)) + :on-click on-separate-nodes + :icon separate-nodes-icon}])]) - [:div {:class (stl/css :sub-actions-group)} - ;; Merge Nodes - [:button {:disabled (not (:merge-nodes enabled-buttons)) - :class (stl/css :topbar-btn) - :title (tr "workspace.path.actions.merge-nodes" (sc/get-tooltip :merge-nodes)) - :on-click on-merge-nodes} - merge-nodes-icon] + (when node-groups-separator-visible? + [:div {:class (stl/css :separator)}]) - ;; Join Nodes - [:button {:disabled (not (:join-nodes enabled-buttons)) - :class (stl/css :topbar-btn) - :title (tr "workspace.path.actions.join-nodes" (sc/get-tooltip :join-nodes)) - :on-click on-join-nodes} - join-nodes-icon] + ;; Node shape and handler-behaviour ops + (when shape-visible? + [:div {:class (stl/css :sub-actions-group)} + (when (:make-corner enabled-buttons) + [:> topbar-button* {:title (tr "workspace.path.actions.make-corner" (sc/get-tooltip :make-corner)) + :on-click on-make-corner + :icon to-corner-icon}]) + (when (:make-curve enabled-buttons) + [:> topbar-button* {:title (tr "workspace.path.actions.make-curve" (sc/get-tooltip :make-curve)) + :on-click on-make-curve + :icon to-curve-icon}])]) - ;; Separate Nodes - [:button {:disabled (not (:separate-nodes enabled-buttons)) - :class (stl/css :topbar-btn) - :title (tr "workspace.path.actions.separate-nodes" (sc/get-tooltip :separate-nodes)) - :on-click on-separate-nodes} - separate-nodes-icon]] + ;; Handler behaviour of the selected node(s) + (when handler-visible? + [:> handler-type-menu* {:active-type active-handler-type + :on-select on-set-handler-type}]) - [:div {:class (stl/css :sub-actions-group)} - ; Make Corner - [:button {:disabled (not (:make-corner enabled-buttons)) - :class (stl/css :topbar-btn) - :title (tr "workspace.path.actions.make-corner" (sc/get-tooltip :make-corner)) - :on-click on-make-corner} - to-corner-icon] + (when middle-visible? + [:div {:class (stl/css :separator)}]) - ;; Make Curve - [:button {:disabled (not (:make-curve enabled-buttons)) - :class (stl/css :topbar-btn) - :title (tr "workspace.path.actions.make-curve" (sc/get-tooltip :make-curve)) - :on-click on-make-curve} - to-curve-icon]] - [:div {:class (stl/css :sub-actions-group)} - ;; Toggle snap - [:button {:class (stl/css-case :is-toggled snap-toggled - :topbar-btn true) + ;; Toggle snap (always visible, pinned to the right) + [:div {:class (stl/css :sub-actions-group :snap-group)} + [:button {:class (stl/css-case :is-toggled snap-toggled :topbar-btn true) :title (tr "workspace.path.actions.snap-nodes" (sc/get-tooltip :snap-nodes)) :on-click on-toggle-snap} - snap-nodes-icon]]])) + snap-icon]]])) diff --git a/frontend/src/app/main/ui/workspace/viewport/path_actions.scss b/frontend/src/app/main/ui/workspace/viewport/path_actions.scss index ca115480b6..2bca2d1a11 100644 --- a/frontend/src/app/main/ui/workspace/viewport/path_actions.scss +++ b/frontend/src/app/main/ui/workspace/viewport/path_actions.scss @@ -2,25 +2,31 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; .sub-actions { cursor: initial; pointer-events: initial; position: absolute; - top: deprecated.$s-12; + top: var(--sp-xl); left: 50%; transform: translateX(-50%); display: flex; align-items: center; - height: deprecated.$s-56; - padding: deprecated.$s-8 deprecated.$s-16; - border-radius: deprecated.$s-8; - gap: deprecated.$s-16; - border: deprecated.$s-2 solid var(--panel-border-color); - z-index: deprecated.$z-index-3; + justify-content: flex-start; + + // Keep the bar stable as actions change. + width: $sz-430; + height: $sz-48; + padding: var(--sp-s); + border-radius: $br-8; + gap: var(--sp-s); + border: $b-2 solid var(--panel-border-color); + z-index: var(--z-index-panels); background-color: var(--color-background-primary); transition: top 0.3s, @@ -33,22 +39,88 @@ display: flex; align-items: center; margin: 0; - opacity: deprecated.$op-10; + opacity: 1; transition: opacity 0.3s ease; } +// Pin the snap toggle to the right. +.snap-group { + margin-left: auto; +} + +.separator { + flex-shrink: 0; + width: $sz-1; + height: $sz-24; + margin: 0 var(--sp-xs); + background-color: var(--panel-border-color); +} + +.handler-menu { + position: relative; + display: flex; + align-items: center; +} + +// Handler behavior flyout trigger. +.handler-trigger { + position: relative; +} + +// Flyout corner indicator. +.flyout-indicator { + position: absolute; + inset-block-end: var(--sp-xs); + inset-inline-end: var(--sp-xs); + inline-size: $sz-6; + block-size: $sz-6; + stroke: var(--pathbar-icon-color); + fill: none; + pointer-events: none; +} + +.handler-popover { + position: absolute; + top: calc(100% + var(--sp-s)); + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + padding: var(--sp-xs); + gap: var(--sp-xxs); + border-radius: $br-8; + border: $b-2 solid var(--panel-border-color); + background-color: var(--color-background-primary); + z-index: var(--z-index-dropdown); + + // Hidden until the flyout opens. + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: + opacity 80ms ease-out, + visibility 80ms linear; + + &.open { + opacity: 1; + visibility: visible; + pointer-events: auto; + } +} + .topbar-btn { --pathbar-icon-color: var(--color-foreground-secondary); + --button-tertiary-background-color-hover: var(--color-background-tertiary); @extend %button-tertiary; - height: deprecated.$s-36; - width: deprecated.$s-36; + height: $sz-32; + width: $sz-32; flex-shrink: 0; background-color: transparent; - border-radius: deprecated.$s-8; + border-radius: $br-8; border: none; - margin: 0 deprecated.$s-2; + margin: 0 var(--sp-xxs); &.is-toggled { --pathbar-icon-color: var(--button-radio-foreground-color-active); diff --git a/frontend/src/app/main/ui/workspace/viewport/path_state.cljs b/frontend/src/app/main/ui/workspace/viewport/path_state.cljs new file mode 100644 index 0000000000..0f1d7da23b --- /dev/null +++ b/frontend/src/app/main/ui/workspace/viewport/path_state.cljs @@ -0,0 +1,39 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.main.ui.workspace.viewport.path-state + (:require + [app.common.types.path :as path] + [app.main.data.workspace.path.state :as path.state] + [app.main.ui.workspace.viewport.drawarea :as drawarea])) + +(defn derive-path-state + "Derives the shared path-editing view model used by classic and WASM viewports." + [edit-path edition drawing-tool drawing-object objects] + (let [edit-state (path.state/current-edit-state edit-path edition) + editing? (path.state/editing? edit-path edition) + drawing? (path.state/drawing? edit-state edition drawing-tool drawing-object) + editing-shape (when edition + (if editing? + drawing-object + (get objects edition))) + editing-shape (if editing? + (path/convert-to-path editing-shape objects) + editing-shape) + bar-state (or edit-state + (when drawing? + (get edit-path (get drawing-object :id)))) + bar-shape (or editing-shape drawing-object) + drawing-shape (if (and editing? edition) + (drawarea/path-edit-shape drawing-object (get objects edition)) + drawing-object)] + {:edit-state edit-state + :editing? editing? + :drawing? drawing? + :editing-shape editing-shape + :bar-state bar-state + :bar-shape bar-shape + :drawing-shape drawing-shape})) diff --git a/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs b/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs index c756f06e52..583e982e7d 100644 --- a/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.pixel-overlay (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.scss b/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.scss index 658fd249a2..8239223684 100644 --- a/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.scss +++ b/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .pixel-overlay { inset: 0; diff --git a/frontend/src/app/main/ui/workspace/viewport/presence.cljs b/frontend/src/app/main/ui/workspace/viewport/presence.cljs index dccba55243..ad57321373 100644 --- a/frontend/src/app/main/ui/workspace/viewport/presence.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/presence.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.presence (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/viewport/presence.scss b/frontend/src/app/main/ui/workspace/viewport/presence.scss index 22660a9fea..8cc58eab61 100644 --- a/frontend/src/app/main/ui/workspace/viewport/presence.scss +++ b/frontend/src/app/main/ui/workspace/viewport/presence.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/viewport/rulers.cljs b/frontend/src/app/main/ui/workspace/viewport/rulers.cljs index 4a8c82cd61..5c3206eba9 100644 --- a/frontend/src/app/main/ui/workspace/viewport/rulers.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/rulers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.rulers (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs b/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs index caae8cb2c8..aefaf4ddf7 100644 --- a/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.scroll-bars (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/selection.cljs b/frontend/src/app/main/ui/workspace/viewport/selection.cljs index 9e497ef8d0..8b75af07f0 100644 --- a/frontend/src/app/main/ui/workspace/viewport/selection.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/selection.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.selection "Selection handlers component." @@ -14,10 +14,14 @@ [app.common.geom.shapes :as gsh] [app.common.types.component :as ctk] [app.common.types.container :as ctn] + [app.common.types.path :as path] + [app.common.types.path.helpers :as path.helpers] [app.common.types.shape :as cts] [app.main.data.helpers :as dsh] [app.main.data.workspace :as dw] [app.main.data.workspace.shapes :as dwsh] + [app.main.data.workspace.wasm-text :as dwwt] + [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.context :as ctx] @@ -26,6 +30,7 @@ [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.object :as obj] + [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def rotation-handler-size 20) @@ -43,7 +48,7 @@ (mf/defc selection-rect {::mf/wrap-props false} - [{:keys [transform rect zoom color on-move-selected on-context-menu]}] + [{:keys [transform rect zoom color on-move-selected on-context-menu hide-outline?]}] (let [x (dm/get-prop rect :x) y (dm/get-prop rect :y) width (dm/get-prop rect :width) @@ -71,13 +76,15 @@ :transform (str transform) :on-pointer-down on-move-selected :on-context-menu on-context-menu - :style {:stroke color + ;; Keep hidden outlines draggable. + :pointer-events (when ^boolean hide-outline? "all") + :style {:stroke (if ^boolean hide-outline? "none" color) :stroke-width (/ selection-rect-width zoom) :fill "none"}}])) (defn- calculate-handlers - "Calculates selection handlers for the current selection." - [selection shape zoom] + "Calculates resize and rotation handles for the selection." + [selection shape zoom only-rotation?] (let [x (dm/get-prop selection :x) y (dm/get-prop selection :y) width (dm/get-prop selection :width) @@ -117,69 +124,72 @@ :props #js {:cx x :cy (+ y height)}}]] - (when-not ^boolean horizontal-line? - (array/conj! result - #js {:type :resize-side - :position :top - :props #js {:x (if ^boolean small-width? - (+ x (/ (- width threshold-small) 2)) - x) - :y y - :length (if ^boolean small-width? - threshold-small - width) - :angle 0 - :align align - :show-handler tiny-width?}} - #js {:type :resize-side - :position :bottom - :props #js {:x (if ^boolean small-width? - (+ x (/ (+ width threshold-small) 2)) - (+ x width)) - :y (+ y height) - :length (if small-width? threshold-small width) - :angle 180 - :align align - :show-handler tiny-width?}})) + (if ^boolean only-rotation? + result + (do + (when-not ^boolean horizontal-line? + (array/conj! result + #js {:type :resize-side + :position :top + :props #js {:x (if ^boolean small-width? + (+ x (/ (- width threshold-small) 2)) + x) + :y y + :length (if ^boolean small-width? + threshold-small + width) + :angle 0 + :align align + :show-handler tiny-width?}} + #js {:type :resize-side + :position :bottom + :props #js {:x (if ^boolean small-width? + (+ x (/ (+ width threshold-small) 2)) + (+ x width)) + :y (+ y height) + :length (if small-width? threshold-small width) + :angle 180 + :align align + :show-handler tiny-width?}})) - (when-not vertical-line? - (array/conj! result - #js {:type :resize-side - :position :right - :props #js {:x (+ x width) - :y (if small-height? (+ y (/ (- height threshold-small) 2)) y) - :length (if small-height? threshold-small height) - :angle 90 - :align align - :show-handler tiny-height?}} + (when-not vertical-line? + (array/conj! result + #js {:type :resize-side + :position :right + :props #js {:x (+ x width) + :y (if small-height? (+ y (/ (- height threshold-small) 2)) y) + :length (if small-height? threshold-small height) + :angle 90 + :align align + :show-handler tiny-height?}} - #js {:type :resize-side - :position :left - :props #js {:x x - :y (if ^boolean small-height? - (+ y (/ (+ height threshold-small) 2)) - (+ y height)) - :length (if ^boolean small-height? - threshold-small - height) - :angle 270 - :align align - :show-handler tiny-height?}})) + #js {:type :resize-side + :position :left + :props #js {:x x + :y (if ^boolean small-height? + (+ y (/ (+ height threshold-small) 2)) + (+ y height)) + :length (if ^boolean small-height? + threshold-small + height) + :angle 270 + :align align + :show-handler tiny-height?}})) - (when (and (not tiny-width?) (not tiny-height?)) - (array/conj! result - #js {:type :resize-point - :position :top-left - :props #js {:cx x :cy y :align align}} - #js {:type :resize-point - :position :top-right - :props #js {:cx (+ x width) :cy y :align align}} - #js {:type :resize-point - :position :bottom-right - :props #js {:cx (+ x width) :cy (+ y height) :align align}} - #js {:type :resize-point - :position :bottom-left - :props #js {:cx x :cy (+ y height) :align align}})))) + (when (and (not tiny-width?) (not tiny-height?)) + (array/conj! result + #js {:type :resize-point + :position :top-left + :props #js {:cx x :cy y :align align}} + #js {:type :resize-point + :position :top-right + :props #js {:cx (+ x width) :cy y :align align}} + #js {:type :resize-point + :position :bottom-right + :props #js {:cx (+ x width) :cy (+ y height) :align align}} + #js {:type :resize-point + :position :bottom-left + :props #js {:cx x :cy (+ y height) :align align}})))))) (mf/defc rotation-handler {::mf/wrap-props false} @@ -295,13 +305,20 @@ on-double-click (mf/use-fn (mf/deps shape-id position shape-type) - (fn [_event] + (fn [event] (when (= shape-type :text) - (cond - (= position :right) - (st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type :auto-width))) - (= position :bottom) - (st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type :auto-height)))))))] + ;; Prevent the viewport double-click handler from entering text editor + (dom/stop-propagation event) + (let [grow-type (case position + :right :auto-width + :bottom :auto-height + nil)] + (when (some? grow-type) + (st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type grow-type))) + ;; The WASM renderer needs an explicit reflow after the grow-type change + (when (features/active-feature? @st/state "render-wasm/v1") + (st/emit! (dwwt/resize-wasm-text-all [shape-id]) + (ptk/data-event :layout/update {:ids [shape-id]}))))))))] [:g.resize-handler (when ^boolean show-handler @@ -321,6 +338,7 @@ :height height :class cursor :data-position (name position) + :data-testid (dm/str "resize-side-handler-" (name position)) :transform transform-str :on-pointer-down on-resize :on-double-click on-double-click @@ -328,7 +346,7 @@ :stroke-width 0}}]])) (mf/defc controls-selection* - [{:keys [shape zoom color on-move-selected on-context-menu disabled]}] + [{:keys [shape zoom color on-move-selected on-context-menu disabled hide-outline?]}] (let [selrect-transform (mf/deref refs/workspace-selrect) transform-type (mf/deref refs/current-transform) [selrect transform] (dsh/get-selrect selrect-transform shape)] @@ -342,12 +360,13 @@ :transform transform :zoom zoom :color color + :hide-outline? hide-outline? :on-move-selected on-move-selected :on-context-menu on-context-menu}]]))) (mf/defc controls-handlers* {::mf/private true} - [{:keys [shape zoom color on-resize on-rotate disabled]}] + [{:keys [shape zoom color on-resize on-rotate disabled only-rotation?]}] (let [selrect-transform (mf/deref refs/workspace-selrect) transform-type (mf/deref refs/current-transform) @@ -374,7 +393,7 @@ (= transform-type :rotate)))) [:g.controls {:pointer-events (if ^boolean disabled "none" "visible")} - (for [handler (calculate-handlers selrect shape zoom)] + (for [handler (calculate-handlers selrect shape zoom only-rotation?)] (let [type (obj/get handler "type") position (obj/get handler "position") props (obj/get handler "props") @@ -482,6 +501,46 @@ :on-move-selected on-move-selected :on-context-menu on-context-menu}])) +(mf/defc line-handlers* + "Endpoint handles for a straight path." + {::mf/private true} + [{:keys [shape zoom color disabled]}] + (let [read-only? (mf/use-ctx ctx/workspace-read-only?) + transform-type (mf/deref refs/current-transform) + content (dm/get-prop shape :content) + p1 (path.helpers/segment->point (nth content 0)) + p2 (path.helpers/segment->point (nth content 1))] + + (when (and (not ^boolean read-only?) + (not (:blocked shape)) + (not (or (= transform-type :move) + (= transform-type :rotate)))) + [:g.controls {:pointer-events (if ^boolean disabled "none" "visible")} + (for [[index point] [[0 p1] [1 p2]]] + (let [x (dm/get-prop point :x) + y (dm/get-prop point :y) + + on-pointer-down + (fn [event] + (when (dom/left-mouse? event) + (dom/stop-propagation event) + (st/emit! (dw/start-move-line-point shape index))))] + [:g.path-point {:key index} + [:circle {:cx x + :cy y + :r (/ resize-point-radius zoom) + :style {:stroke-width "1px" + :stroke color + :fill "var(--app-white)" + :vectorEffect "non-scaling-stroke"}}] + [:circle {:cx x + :cy y + :r (/ resize-point-circle-radius zoom) + :on-pointer-down on-pointer-down + :class (cur/get-static "pointer-node") + :style {:fill (if (dbg/enabled? :handlers) "red" "none") + :stroke-width 0}}]]))]))) + (mf/defc single-handlers* {::mf/private true} [{:keys [shape zoom color disabled]}] @@ -489,6 +548,9 @@ grow-type (dm/get-prop shape :grow-type) shape-type (dm/get-prop shape :type) + line? (and (cfh/path-shape? shape) + (path/single-line? (dm/get-prop shape :content))) + on-resize (mf/use-fn (mf/deps shape-id shape grow-type shape-type) @@ -526,24 +588,44 @@ (dom/stop-propagation event) (st/emit! (dw/start-rotate [shape])))))] - [:> controls-handlers* - {:shape shape - :zoom zoom - :color color - :disabled disabled - :on-rotate on-rotate - :on-resize on-resize}])) + (if ^boolean line? + [:g.line-controls + ;; Use endpoint controls with corner rotation handles. + [:> controls-handlers* + {:shape shape + :zoom zoom + :color color + :disabled disabled + :on-rotate on-rotate + :on-resize on-resize + :only-rotation? true}] + [:> line-handlers* + {:shape shape + :zoom zoom + :color color + :disabled disabled}]] + [:> controls-handlers* + {:shape shape + :zoom zoom + :color color + :disabled disabled + :on-rotate on-rotate + :on-resize on-resize}]))) (mf/defc single-selection* {::mf/private true} [{:keys [shape zoom color disabled on-move-selected on-context-menu]}] - [:> controls-selection* - {:shape shape - :zoom zoom - :color color - :disabled disabled - :on-move-selected on-move-selected - :on-context-menu on-context-menu}]) + (let [line? (and (cfh/path-shape? shape) + (path/single-line? (dm/get-prop shape :content)))] + [:> controls-selection* + {:shape shape + :zoom zoom + :color color + :disabled disabled + ;; Keep the line body draggable without an outline. + :hide-outline? line? + :on-move-selected on-move-selected + :on-context-menu on-context-menu}])) (mf/defc area* [{:keys [shapes edition zoom disabled on-move-selected on-context-menu]}] diff --git a/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs b/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs index 7ad5f0e71d..2683aea88d 100644 --- a/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.snap-distances (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs b/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs index 6bda018c1a..409a6ad153 100644 --- a/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.snap-points (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs b/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs index c92c357d25..5d625d16fe 100644 --- a/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.top-bar (:require-macros [app.main.style :as stl]) @@ -56,3 +56,23 @@ (mf/defc grid-edition-bar* [{:keys [shape]}] [:> grid-edition-actions* {:shape shape}]) + +(mf/defc edition-bars* + [{:keys [layout + path-editing + path-drawing + path-state + path-shape + grid-editing + grid-shape + single-select]}] + [:* + (when (or (and ^boolean path-editing ^boolean single-select) + (and ^boolean path-drawing (some? path-state))) + [:> path-edition-bar* {:shape path-shape + :edit-path-state path-state + :layout layout}]) + + (when (and ^boolean grid-editing ^boolean single-select) + [:> grid-edition-bar* {:shape grid-shape}])]) + diff --git a/frontend/src/app/main/ui/workspace/viewport/top_bar.scss b/frontend/src/app/main/ui/workspace/viewport/top_bar.scss index 0ef9df6fd6..255ae85080 100644 --- a/frontend/src/app/main/ui/workspace/viewport/top_bar.scss +++ b/frontend/src/app/main/ui/workspace/viewport/top_bar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/viewport/utils.cljs b/frontend/src/app/main/ui/workspace/viewport/utils.cljs index 7858a07f46..4e120af115 100644 --- a/frontend/src/app/main/ui/workspace/viewport/utils.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/utils.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.utils (:require @@ -29,6 +29,8 @@ :create-ellipse (cur/get-static "create-ellipse") :pen (cur/get-static "pen") :pencil (cur/get-static "pencil") + :draw-path (cur/get-static "draw") + :edit-path (cur/get-static "move") :create-shape (cur/get-static "create-shape") :duplicate (cur/get-static "duplicate") :zoom (cur/get-static "zoom") diff --git a/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs b/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs index 38349f8f24..fa1c126307 100644 --- a/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.viewport-ref (:require @@ -17,6 +17,11 @@ (defonce viewport-ref (atom nil)) (defonce viewport-brect (atom nil)) +(defn capture-pointer + [event] + (when-let [viewport @viewport-ref] + (.setPointerCapture viewport (.-pointerId event)))) + (defn- init-observer [node] (let [on-change-bounds diff --git a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs index 5521d5bbd6..aa83faf963 100644 --- a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.widgets (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/viewport/widgets.scss b/frontend/src/app/main/ui/workspace/viewport/widgets.scss index f2a05d1042..d1f19300e5 100644 --- a/frontend/src/app/main/ui/workspace/viewport/widgets.scss +++ b/frontend/src/app/main/ui/workspace/viewport/widgets.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index b55d8c554c..6badb43703 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport-wasm (:require-macros [app.main.style :as stl]) @@ -13,7 +13,6 @@ [app.common.geom.shapes :as gsh] [app.common.types.color :as clr] [app.common.types.component :as ctk] - [app.common.types.path :as path] [app.common.types.shape :as cts] [app.common.types.shape.layout :as ctl] [app.main.data.modal :as modal] @@ -44,6 +43,7 @@ [app.main.ui.workspace.viewport.hooks :as hooks] [app.main.ui.workspace.viewport.interactions :as interactions] [app.main.ui.workspace.viewport.outline :as outline] + [app.main.ui.workspace.viewport.path-state :as path-state] [app.main.ui.workspace.viewport.pixel-overlay :as pixel-overlay] [app.main.ui.workspace.viewport.presence :as presence] [app.main.ui.workspace.viewport.rulers :as rulers] @@ -51,8 +51,7 @@ [app.main.ui.workspace.viewport.selection :as selection] [app.main.ui.workspace.viewport.snap-distances :as snap-distances] [app.main.ui.workspace.viewport.snap-points :as snap-points] - [app.main.ui.workspace.viewport.top-bar :refer [grid-edition-bar* - path-edition-bar* + [app.main.ui.workspace.viewport.top-bar :refer [edition-bars* view-only-bar*]] [app.main.ui.workspace.viewport.utils :as utils] [app.main.ui.workspace.viewport.viewport-ref :as vp-ref :refer [create-viewport-ref]] @@ -282,21 +281,22 @@ ;; Only when we have all the selected shapes in one frame selected-frame (when (= (count selected-frames) 1) (get base-objects (first selected-frames))) - edit-path-state (get edit-path edition) - edit-path-mode (get edit-path-state :edit-mode) + {:keys [edit-state + editing? + drawing? + editing-shape + bar-state + bar-shape + drawing-shape]} + (mf/with-memo [edit-path edition drawing-tool drawing-obj base-objects] + (path-state/derive-path-state edit-path edition drawing-tool drawing-obj base-objects)) - path-editing? (some? edit-path-state) - path-drawing? (or (= edit-path-mode :draw) - (and (= :path (get drawing-obj :type)) - (not= :curve drawing-tool))) - - editing-shape (when edition - (get base-objects edition)) - - editing-shape (mf/with-memo [editing-shape path-editing? base-objects] - (if path-editing? - (path/convert-to-path editing-shape base-objects) - editing-shape)) + edit-path-state edit-state + path-editing? editing? + path-drawing? drawing? + path-bar-state bar-state + path-bar-shape bar-shape + draw-area-shape drawing-shape create-comment? (= :comments drawing-tool) @@ -372,8 +372,9 @@ show-snap-points? (and (or (contains? layout :dynamic-alignment) (contains? layout :snap-guides)) (or drawing-obj transform) + (not path-editing?) (not page-transition?)) - show-selrect? (and selrect (empty? drawing) (not text-editing?) (not page-transition?)) + show-selrect? (and selrect (or (empty? drawing) path-editing?) (not text-editing?) (not page-transition?)) show-measures? (and (not transform) (not path-editing?) (or show-distances? mode-inspect? read-only?) @@ -629,13 +630,24 @@ (hooks/setup-dom-events zoom disable-paste-ref in-viewport-ref read-only? drawing-tool path-drawing?) (hooks/setup-viewport-size vport viewport-ref) - (hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?) + (hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? (get path-bar-state :drag-cursor) z? read-only?) (hooks/setup-keyboard alt? mod? space? z? shift?) (hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only? transform) (hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?) (hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox) + (mf/with-effect [path-editing? edition @initialized?] + (when (and path-editing? edition @initialized?) + (wasm.api/use-shape edition) + (wasm.api/set-shape-hidden true) + (wasm.api/request-render "start-path-edition") + (fn [] + (when (wasm.api/initialized?) + (wasm.api/use-shape edition) + (wasm.api/set-shape-hidden false) + (wasm.api/request-render "stop-path-edition"))))) + [:div {:class (stl/css :viewport) :style #js {"--zoom" zoom} :data-testid "viewport"} (cond @@ -650,15 +662,14 @@ (when-not hide-ui? [:> top-toolbar* {:layout layout}]) - (when (and ^boolean path-editing? - ^boolean single-select?) - [:> path-edition-bar* {:shape editing-shape - :edit-path-state edit-path-state - :layout layout}]) - - (when (and ^boolean grid-editing? - ^boolean single-select?) - [:> grid-edition-bar* {:shape editing-shape}])]) + [:> edition-bars* {:layout layout + :path-editing path-editing? + :path-drawing path-drawing? + :path-state path-bar-state + :path-shape path-bar-shape + :grid-editing grid-editing? + :grid-shape editing-shape + :single-select single-select?}]]) [:div {:class (stl/css :viewport-overlays)} (when show-comments? @@ -666,7 +677,8 @@ :page-id page-id :file-id file-id :vport vport - :zoom zoom}]) + :zoom zoom + :show-rulers show-rulers?}]) (when picking-color? [:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref @@ -708,7 +720,8 @@ :global/cursor-resize-ew-0 (= @guide-hover-axis* :x) :global/cursor-resize-ns-0 (= @guide-hover-axis* :y) :viewport-controls true)) - :style {:touch-action "none"} + :style {:touch-action "none" + :pointer-events (if page-transition? "none" "auto")} :fill "none" :on-click on-click :on-context-menu on-context-menu @@ -746,8 +759,8 @@ :canvas-ref canvas-ref :ref text-editor-ref}] - :else [:> editor-v1/text-editor-svg* {:shape editing-shape - :ref text-editor-ref}])) + :else [:& editor-v1/text-editor-svg {:shape editing-shape + :ref text-editor-ref}])) (when show-frame-outline? (let [outlined-frame-id (->> @hover-ids @@ -864,7 +877,7 @@ (when (and ^boolean show-draw-area? ^boolean (cts/shape? drawing-obj)) [:> drawarea/draw-area* - {:shape drawing-obj + {:shape draw-area-shape :zoom zoom :tool drawing-tool}]) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.scss b/frontend/src/app/main/ui/workspace/viewport_wasm.scss index 1c721e7cbe..7ead5ecb07 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.scss +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .viewport { cursor: none; diff --git a/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.cljs b/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.cljs index f4092a4762..c73b0ecf6a 100644 --- a/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.cljs +++ b/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.webgl-unavailable-modal (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.scss b/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.scss index d57571ef0c..ed90d4387b 100644 --- a/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.scss +++ b/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/worker.cljs b/frontend/src/app/main/worker.cljs index 21032ffd54..bbfec651c6 100644 --- a/frontend/src/app/main/worker.cljs +++ b/frontend/src/app/main/worker.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.worker "Interface to communicate with the web worker" diff --git a/frontend/src/app/plugins.cljs b/frontend/src/app/plugins.cljs index 8937b4dde6..921cf740b9 100644 --- a/frontend/src/app/plugins.cljs +++ b/frontend/src/app/plugins.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins "RPC for plugins runtime." diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index bea36ad027..643e793787 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.api "RPC for plugins runtime." @@ -28,7 +28,6 @@ [app.main.data.workspace.groups :as dwg] [app.main.data.workspace.media :as dwm] [app.main.data.workspace.pages :as dwpg] - [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.variants :as dwv] [app.main.data.workspace.wasm-text :as dwwt] @@ -47,6 +46,7 @@ [app.plugins.local-storage :as local-storage] [app.plugins.page :as page] [app.plugins.parser :as parser] + [app.plugins.reflow :as wrfp] [app.plugins.shape :as shape] [app.plugins.system-events :as se] [app.plugins.user :as user] @@ -416,7 +416,10 @@ (cb/with-objects (:objects page)) (cb/add-object shape))] - (st/emit! (ch/commit-changes changes) + ;; Track the commit until the renderer starts. + (st/emit! (ptk/data-event :text/reflow {:ids [(:id shape)] + :page-id (:id page)}) + (ch/commit-changes changes) (se/event plugin-id "create-shape" :type :text)) (when (features/active-feature? @st/state "render-wasm/v1") @@ -734,10 +737,5 @@ :waitForLayoutUpdate (fn [timeout] - ;; Always a promise, so a bad argument travels as a rejection. - (if (u/valid-timeout? timeout) - ;; Resolves once every shape with reflow work in flight has settled. - (wrf/wait-for-layout-update timeout) - (js/Promise. - (fn [_ reject] - (u/reject-not-valid reject :waitForLayoutUpdate timeout))))))) + ;; Resolves once every shape with reflow work in flight has settled. + (wrfp/wait-for-layout-update timeout)))) diff --git a/frontend/src/app/plugins/comments.cljs b/frontend/src/app/plugins/comments.cljs index 71e8a0311b..1ee7ee6550 100644 --- a/frontend/src/app/plugins/comments.cljs +++ b/frontend/src/app/plugins/comments.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.comments (:require diff --git a/frontend/src/app/plugins/events.cljs b/frontend/src/app/plugins/events.cljs index 17e50140a1..7b52ba65f1 100644 --- a/frontend/src/app/plugins/events.cljs +++ b/frontend/src/app/plugins/events.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.events (:require diff --git a/frontend/src/app/plugins/exports.cljs b/frontend/src/app/plugins/exports.cljs index 0363fdf582..3b804dcea7 100644 --- a/frontend/src/app/plugins/exports.cljs +++ b/frontend/src/app/plugins/exports.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.exports (:require diff --git a/frontend/src/app/plugins/file.cljs b/frontend/src/app/plugins/file.cljs index 12049611bd..74d2c141ca 100644 --- a/frontend/src/app/plugins/file.cljs +++ b/frontend/src/app/plugins/file.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.file (:require @@ -258,7 +258,13 @@ (fn [format type] (js/Promise. (fn [resolve reject] - (let [type (or (parser/parse-keyword type) :all)] + (let [type (or (parser/parse-keyword type) :all) + ;; Backward compatibility: convert old values to new + type (case type + :all :include-libraries + :merge :merge-libraries + :detach :detach-libraries + type)] (cond (and (some? format) (not (contains? #{"penpot" "zip"} format))) (u/reject-not-valid reject :format (dm/str "Invalid format: " format)) diff --git a/frontend/src/app/plugins/fills.cljs b/frontend/src/app/plugins/fills.cljs index 15895ed12b..586eb4d198 100644 --- a/frontend/src/app/plugins/fills.cljs +++ b/frontend/src/app/plugins/fills.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.fills (:require diff --git a/frontend/src/app/plugins/flags.cljs b/frontend/src/app/plugins/flags.cljs index b804df4d9c..cf9f7be805 100644 --- a/frontend/src/app/plugins/flags.cljs +++ b/frontend/src/app/plugins/flags.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.flags (:require diff --git a/frontend/src/app/plugins/flex.cljs b/frontend/src/app/plugins/flex.cljs index 0967edcbec..da3d686705 100644 --- a/frontend/src/app/plugins/flex.cljs +++ b/frontend/src/app/plugins/flex.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.flex (:require diff --git a/frontend/src/app/plugins/fonts.cljs b/frontend/src/app/plugins/fonts.cljs index 2ae009ce9d..3f460a83d3 100644 --- a/frontend/src/app/plugins/fonts.cljs +++ b/frontend/src/app/plugins/fonts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.fonts (:require diff --git a/frontend/src/app/plugins/format.cljs b/frontend/src/app/plugins/format.cljs index 8680a6d8a0..f6f171705e 100644 --- a/frontend/src/app/plugins/format.cljs +++ b/frontend/src/app/plugins/format.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.format (:require diff --git a/frontend/src/app/plugins/gradients.cljs b/frontend/src/app/plugins/gradients.cljs index 809c8c768e..8ad1846621 100644 --- a/frontend/src/app/plugins/gradients.cljs +++ b/frontend/src/app/plugins/gradients.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.gradients (:require diff --git a/frontend/src/app/plugins/grid.cljs b/frontend/src/app/plugins/grid.cljs index 96deccb857..15771ff7b3 100644 --- a/frontend/src/app/plugins/grid.cljs +++ b/frontend/src/app/plugins/grid.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.grid (:require diff --git a/frontend/src/app/plugins/history.cljs b/frontend/src/app/plugins/history.cljs index ea1ee573a1..68eb122506 100644 --- a/frontend/src/app/plugins/history.cljs +++ b/frontend/src/app/plugins/history.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.history (:require diff --git a/frontend/src/app/plugins/image_data.cljs b/frontend/src/app/plugins/image_data.cljs index bcdee4e6eb..97f4a578b3 100644 --- a/frontend/src/app/plugins/image_data.cljs +++ b/frontend/src/app/plugins/image_data.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.image-data (:require diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index 5839ed57a4..c7895ef7fc 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.library (:require diff --git a/frontend/src/app/plugins/local_storage.cljs b/frontend/src/app/plugins/local_storage.cljs index 1b24d520ed..fa578fcbb5 100644 --- a/frontend/src/app/plugins/local_storage.cljs +++ b/frontend/src/app/plugins/local_storage.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.local-storage (:require diff --git a/frontend/src/app/plugins/page.cljs b/frontend/src/app/plugins/page.cljs index e668bc8756..28a674f111 100644 --- a/frontend/src/app/plugins/page.cljs +++ b/frontend/src/app/plugins/page.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.page (:require diff --git a/frontend/src/app/plugins/parser.cljs b/frontend/src/app/plugins/parser.cljs index cbadff48f4..5923289407 100644 --- a/frontend/src/app/plugins/parser.cljs +++ b/frontend/src/app/plugins/parser.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.parser (:require diff --git a/frontend/src/app/plugins/public_utils.cljs b/frontend/src/app/plugins/public_utils.cljs index 3a303fe888..7f34068fcd 100644 --- a/frontend/src/app/plugins/public_utils.cljs +++ b/frontend/src/app/plugins/public_utils.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.public-utils "Utilities that will be exposed to plugins developers" diff --git a/frontend/src/app/plugins/reflow.cljs b/frontend/src/app/plugins/reflow.cljs new file mode 100644 index 0000000000..da92d7c1a2 --- /dev/null +++ b/frontend/src/app/plugins/reflow.cljs @@ -0,0 +1,77 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.plugins.reflow + "Promise adapter for the plugin `waitForLayoutUpdate` methods. Owns the + argument validation, the default deadline and the rejection shape; the + workspace only reports when its pending work has drained." + (:require + [app.common.data.macros :as dm] + [app.common.files.helpers :as cfh] + [app.common.uuid :as uuid] + [app.main.data.workspace.reflow :as wrf] + [beicon.v2.core :as rx])) + +;; Ceiling for callers that pass no timeout, so a pipeline that never drains +;; its marks rejects the promise rather than leaving it unsettled. +(def ^:private default-timeout 30000) + +;; Largest value a signed 32-bit timer accepts. +(def ^:private max-timeout 2147483647) + +(defn- valid-timeout? + "Checks that a plugin timeout fits a signed 32-bit timer." + [value] + (or (nil? value) + (and (number? value) + (pos? value) + (<= value max-timeout) + (js/Number.isFinite value)))) + +(defn- reject-invalid! + [reject value] + (let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value + ". Code: " :waitForLayoutUpdate)] + (.error js/console msg) + (reject (js/Error. msg)))) + +(defn shape-wait-ids + "Ids a per-shape wait covers: the shape subtree, its ancestors, and the file + its components sync from." + [objects file-id id] + (-> (into #{} (cfh/get-children-ids-with-self objects id)) + (into (cfh/get-parent-ids objects id)) + (conj file-id) + (disj uuid/zero))) + +(defn wait-for-layout-update + "Returns a JS Promise that resolves once every id in `ids` has drained from + the workspace pending map. A nil `ids` waits for every pending id; an empty + one has nothing to wait for and resolves right away. + + The promise is rejected when `timeout` (ms) is not a valid timer value, or + when it elapses first; a nil `timeout` uses `default-timeout`." + ([timeout] + (wait-for-layout-update nil timeout)) + ([ids timeout] + (js/Promise. + (fn [resolve reject] + (if-not (valid-timeout? timeout) + (reject-invalid! reject timeout) + ;; Race the settle signal against the deadline; the loser is + ;; unsubscribed. `settled` replays on subscribe, so an already drained + ;; map wins even against a 1ms deadline. + (->> (rx/race (->> (rx/of :timeout) + (rx/delay (or timeout default-timeout))) + (->> (wrf/settled ids) + (rx/map (constantly :ok)))) + (rx/take 1) + (rx/subs! + (fn [value] + (if (= value :timeout) + (reject (js/Error. "waitForLayoutUpdate timeout")) + (resolve))) + reject))))))) diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs index e4837ce75b..2a3720c65e 100644 --- a/frontend/src/app/plugins/register.cljs +++ b/frontend/src/app/plugins/register.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.register (:require @@ -35,7 +35,7 @@ "Signals that plugins runtime has been initialized. Called by app.plugins/init-plugins-runtime." [] (when (p/pending? runtime-ready-promise) - (p/resolve! runtime-ready-promise true))) + (p/resolve runtime-ready-promise true))) ;; Stores the installed plugins information (defonce ^:private registry (atom {})) @@ -112,13 +112,6 @@ manifest (.error js/console (clj->js (sm/explain ctp/schema:registry-entry manifest)))))) -(defn save-to-store - [] - ;; TODO: need this for the transition to the new schema. We can remove eventually - (let [registry (update @registry :data d/update-vals d/without-nils)] - (->> (rp/cmd! :update-profile-props {:props {:plugins registry}}) - (rx/subs! identity)))) - (defn load-from-store [] (reset! registry (get-in @st/state [:profile :props :plugins] {}))) @@ -127,6 +120,8 @@ [] (load-from-store)) +(declare remove-plugin!) + (defn install-plugin! [plugin] (letfn [(update-ids [ids] @@ -136,17 +131,27 @@ (swap! registry #(-> % (update :ids update-ids) (update :data assoc (:plugin-id plugin) plugin))) - (save-to-store))) + (->> (rp/cmd! :add-profile-plugin {:plugin plugin}) + (rx/subs! identity + (fn [err] + (remove-plugin! plugin) + (.error js/console "Failed to install plugin:" err)))))) (defn remove-plugin! [{:keys [plugin-id]}] - (letfn [(update-ids [ids] - (->> ids - (remove #(= % plugin-id))))] - (swap! registry #(-> % - (update :ids update-ids) - (update :data dissoc plugin-id))) - (save-to-store))) + (let [plugin (get-plugin plugin-id)] + (letfn [(update-ids [ids] + (->> ids + (remove #(= % plugin-id))))] + (swap! registry #(-> % + (update :ids update-ids) + (update :data dissoc plugin-id))) + (->> (rp/cmd! :remove-profile-plugin {:plugin-id plugin-id}) + (rx/subs! identity + (fn [err] + (when plugin + (install-plugin! plugin)) + (.error js/console "Failed to remove plugin:" err))))))) (defn check-permission [plugin-id permission] diff --git a/frontend/src/app/plugins/ruler_guides.cljs b/frontend/src/app/plugins/ruler_guides.cljs index 75658c14fe..56dd051f1e 100644 --- a/frontend/src/app/plugins/ruler_guides.cljs +++ b/frontend/src/app/plugins/ruler_guides.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.ruler-guides (:require diff --git a/frontend/src/app/plugins/shadows.cljs b/frontend/src/app/plugins/shadows.cljs index 866ae5658d..eb45b16401 100644 --- a/frontend/src/app/plugins/shadows.cljs +++ b/frontend/src/app/plugins/shadows.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.shadows (:require diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 88177b39ab..44059d3244 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.shape (:require @@ -43,7 +43,6 @@ [app.main.data.workspace.guides :as dwgu] [app.main.data.workspace.interactions :as dwi] [app.main.data.workspace.libraries :as dwl] - [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.shape-layout :as dwsl] [app.main.data.workspace.shapes :as dwsh] @@ -58,6 +57,7 @@ [app.plugins.format :as format] [app.plugins.grid :as grid] [app.plugins.parser :as parser] + [app.plugins.reflow :as wrfp] [app.plugins.register :as r] [app.plugins.ruler-guides :as rg] [app.plugins.shadows :as shadows] @@ -1057,15 +1057,11 @@ :waitForLayoutUpdate (fn [timeout] - ;; Always a promise, so a bad argument travels as a rejection. - (if (u/valid-timeout? timeout) - ;; Resolves once the reflow work of this shape's subtree has - ;; settled: it can be marked on the shape or on its descendants. - (let [objects (u/locate-objects file-id page-id)] - (wrf/wait-for-layout-update (cfh/get-children-ids-with-self objects id) timeout)) - (js/Promise. - (fn [_ reject] - (u/reject-not-valid reject :waitForLayoutUpdate timeout))))) + ;; Wait for layout work that can affect this shape. + (let [objects (u/locate-objects file-id page-id)] + (wrfp/wait-for-layout-update + (wrfp/shape-wait-ids objects file-id id) + timeout))) ;; Plugin data :getPluginData diff --git a/frontend/src/app/plugins/strokes.cljs b/frontend/src/app/plugins/strokes.cljs index 1a717bb21c..7ccfe1bc11 100644 --- a/frontend/src/app/plugins/strokes.cljs +++ b/frontend/src/app/plugins/strokes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.strokes (:require diff --git a/frontend/src/app/plugins/system_events.cljs b/frontend/src/app/plugins/system_events.cljs index 44d75c3752..716852d152 100644 --- a/frontend/src/app/plugins/system_events.cljs +++ b/frontend/src/app/plugins/system_events.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.system-events (:require diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs index 3692ae1a59..901deb9f5b 100644 --- a/frontend/src/app/plugins/text.cljs +++ b/frontend/src/app/plugins/text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.text (:require @@ -499,10 +499,10 @@ (u/not-valid plugin-id :growType "Cannot modify a page that is not currently active") :else - (st/emit! - (dwsh/update-shapes [id] #(assoc % :grow-type value)) - (when (features/active-feature? @st/state "render-wasm/v1") - (st/emit! (dwwt/resize-wasm-text-debounce id)))))))} + (do + (st/emit! (dwsh/update-shapes [id] #(assoc % :grow-type value))) + (when (features/active-feature? @st/state "render-wasm/v1") + (st/emit! (dwwt/resize-wasm-text-debounce id)))))))} {:name "fontId" :get #(-> % u/proxy->shape text-props :font-id format/format-mixed) diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index 64bc69695f..8825775e6f 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.tokens (:require diff --git a/frontend/src/app/plugins/tracks.cljs b/frontend/src/app/plugins/tracks.cljs index 643f6657e5..c5f38664f2 100644 --- a/frontend/src/app/plugins/tracks.cljs +++ b/frontend/src/app/plugins/tracks.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.tracks (:require diff --git a/frontend/src/app/plugins/user.cljs b/frontend/src/app/plugins/user.cljs index 1642759a55..72d146f6c8 100644 --- a/frontend/src/app/plugins/user.cljs +++ b/frontend/src/app/plugins/user.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.user (:require diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index 21afd5cdde..9e4057f1a7 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.utils "RPC for plugins runtime." @@ -282,8 +282,7 @@ [code value] (if (some? value) (throw (js/Error. (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code))) - (throw (js/Error. (dm/str "[PENPOT PLUGIN] Value not valid. Code: " code)))) - nil) + (throw (js/Error. (dm/str "[PENPOT PLUGIN] Value not valid. Code: " code))))) (defn not-valid [plugin-id code value] @@ -291,14 +290,6 @@ (throw-not-valid code value) (display-not-valid code value))) -(defn valid-timeout? - "A plugin timeout argument: omitted, or a finite positive number of msecs." - [value] - (or (nil? value) - (and (number? value) - (pos? value) - (js/Number.isFinite value)))) - (defn reject-not-valid [reject code value] (let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)] diff --git a/frontend/src/app/plugins/viewport.cljs b/frontend/src/app/plugins/viewport.cljs index 333c13a6e1..b09a664103 100644 --- a/frontend/src/app/plugins/viewport.cljs +++ b/frontend/src/app/plugins/viewport.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.viewport (:require diff --git a/frontend/src/app/rasterizer.cljs b/frontend/src/app/rasterizer.cljs index ed516fc85f..ca62fd508f 100644 --- a/frontend/src/app/rasterizer.cljs +++ b/frontend/src/app/rasterizer.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rasterizer "A main entry point for the rasterizer process that is diff --git a/frontend/src/app/render.cljs b/frontend/src/app/render.cljs index 99dfed871a..f758822d2b 100644 --- a/frontend/src/app/render.cljs +++ b/frontend/src/app/render.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render "The main entry point for UI part needed by the exporter." @@ -136,7 +136,9 @@ (repo/cmd! :get-page {:file-id file-id :page-id page-id :share-id share-id - :object-id object-id + :object-id (if (uuid? object-id) + object-id + (set object-id)) :features features})) (rx/tap (fn [[fonts]] (when (seq fonts) @@ -155,7 +157,7 @@ [:embed {:optional true} :boolean] [:skip-children {:optional true} :boolean] [:object-id - [:or [::sm/set ::sm/uuid] ::sm/uuid]]]) + [:or [:vector ::sm/uuid] ::sm/uuid]]]) (def ^:private coerce-render-objects-params (sm/coercer schema:render-objects)) @@ -188,7 +190,7 @@ {:file-id file-id :page-id page-id :share-id share-id - :object-ids (into #{} object-id) + :object-ids (into [] (distinct) object-id) :embed embed :skip-children skip-children :wasm wasm diff --git a/frontend/src/app/render_wasm.cljs b/frontend/src/app/render_wasm.cljs index 0672c723a8..62c1b760c3 100644 --- a/frontend/src/app/render_wasm.cljs +++ b/frontend/src/app/render_wasm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm "A WASM based render API" diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index c24bcf0460..a9c1718580 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.api "A WASM based render API" @@ -13,8 +13,18 @@ [app.common.exceptions :as ex] [app.common.files.focus :as cpf] [app.common.files.helpers :as cfh] + [app.common.fonts :as cfnt] [app.common.logging :as log] [app.common.math :as mth] + [app.common.render-wasm.api.props :as props] + [app.common.render-wasm.api.upload :as upload] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.mem.heap32 :as mem.h32] + [app.common.render-wasm.serialize-shape :as serialize-shape] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.serializers.color :as sr-clr] + [app.common.render-wasm.wasm :as wasm] [app.common.types.color :as clr] [app.common.types.fills :as types.fills] [app.common.types.path :as path] @@ -31,23 +41,17 @@ [app.main.router :as rt] [app.main.store :as st] [app.main.ui.shapes.text] + ;; Required for side effects: binds the generated enums. + [app.render-wasm.api.enums] [app.render-wasm.api.fonts :as f] - [app.render-wasm.api.props :as props] [app.render-wasm.api.texts :as t] [app.render-wasm.api.webgl :as webgl] [app.render-wasm.deserializers :as dr] [app.render-wasm.gesture :as wasm-gesture] - [app.render-wasm.helpers :as h] - [app.render-wasm.mem :as mem] - [app.render-wasm.mem.heap32 :as mem.h32] [app.render-wasm.performance :as perf] [app.render-wasm.rulers-state :as rulers-state] - [app.render-wasm.serialize-shape :as serialize-shape] - [app.render-wasm.serializers :as sr] - [app.render-wasm.serializers.color :as sr-clr] [app.render-wasm.svg-filters :as svg-filters] [app.render-wasm.text-editor :as text-editor] - [app.render-wasm.wasm :as wasm] [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.functions :as fns] @@ -64,6 +68,15 @@ (def use-dpr? (contains? cf/flags :render-wasm-dpr)) +(defn- wasm-get-numeric-value + "Read a positive numeric query param (e.g. `?dpr=2`)." + [name] + (when-let [raw (let [p (rt/get-params @st/state)] + (get p name))] + (let [n (if (string? raw) (js/parseFloat raw) raw)] + (when (and (number? n) (not (js/isNaN n)) (pos? n)) + n)))) + ;; --- Page transition state (WASM viewport) ;; ;; Goal: avoid showing tile-by-tile rendering during page switches (and initial load), @@ -71,6 +84,7 @@ ;; `penpot:wasm:tiles-complete`. ;; ;; - `page-transition?`: true while the overlay should be considered active. +;; Pan/zoom into WASM is frozen until `tiles-complete` (atlas still empty). ;; - `transition-image*`: image shown by the UI overlay (usually an `ImageBitmap` ;; snapshot of the WebGL canvas; on initial load it may be a tiny SVG data-url ;; string derived from the page background color). @@ -80,6 +94,8 @@ ;; `penpot:wasm:tiles-complete`, so we can remove/replace it safely. (defonce page-transition? (atom false)) (defonce context-loss-overlay? (atom false)) +;; Skipped set-view-box during transition; flushed when the overlay ends. +(defonce ^:private viewport-dirty-during-transition? (atom false)) ;; When true (initial load) the overlay clips out the ruler strips so the live ;; rulers show through. False (page switch / context loss) keeps the snapshot's ;; baked-in rulers full-bleed to avoid a blank-strip flicker on canvas remount. @@ -102,6 +118,8 @@ [] (wasm/ready?)) +(declare sync-workspace-local-viewport!) + (defn set-transition-image-from-background! "Sets `transition-image*` to a data URL representing a solid background color." @@ -116,6 +134,7 @@ (defn begin-page-transition! [] (reset! page-transition? true) + (reset! viewport-dirty-during-transition? false) (swap! transition-epoch* inc)) (defn end-page-transition! @@ -124,7 +143,11 @@ (when-let [prev @transition-tiles-handler*] (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev)) (reset! transition-tiles-handler* nil) - (reset! transition-image* nil)) + (reset! transition-image* nil) + ;; Keyboard/wheel may have moved workspace-local while WASM was frozen. + (when (and (initialized?) @viewport-dirty-during-transition?) + (reset! viewport-dirty-during-transition? false) + (sync-workspace-local-viewport! @st/state))) (defn- set-transition-tiles-complete-handler! "Installs a tiles-complete handler bound to the current transition epoch. @@ -258,8 +281,6 @@ (defonce ^:private view-interaction-active? (atom false)) -;; Time budget (ms) per chunk of shape processing before yielding to browser -(def ^:private ^:const CHUNK_TIME_BUDGET_MS 8) ;; Threshold below which we use synchronous processing (no chunking overhead) (def ^:const ASYNC_THRESHOLD 100) @@ -281,6 +302,7 @@ (def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point) (def text-editor-toggle-overtype-mode text-editor/text-editor-toggle-overtype-mode) (def text-editor-pointer-down text-editor/text-editor-pointer-down) +(def text-editor-pointer-down-extend text-editor/text-editor-pointer-down-extend) (def text-editor-pointer-move text-editor/text-editor-pointer-move) (def text-editor-pointer-up text-editor/text-editor-pointer-up) (def text-editor-get-current-styles text-editor/text-editor-get-current-styles) @@ -289,6 +311,7 @@ (def text-editor-get-active-shape-id text-editor/text-editor-get-active-shape-id) (def text-editor-select-all text-editor/text-editor-select-all) (def text-editor-select-word-boundary text-editor/text-editor-select-word-boundary) +(def text-editor-select-paragraph text-editor/text-editor-select-paragraph) (def text-editor-sync-content text-editor/text-editor-sync-content) (def dpr @@ -296,14 +319,18 @@ (defn get-dpr "Returns the current device pixel ratio. Use instead of `dpr` wherever - the value must reflect browser-zoom changes that happen after load." + the value must reflect browser-zoom changes that happen after load. + + Override with query param `?dpr=2` (or any positive number) for HiDPI repro + without relying on the real `devicePixelRatio`." [] - (if use-dpr? - (let [d (.-devicePixelRatio ^js ug/window)] - ;; In workers `ug/window` is a mock without `devicePixelRatio`, - ;; so guard against nil/NaN/non-positive values. - (if (and (number? d) (pos? d)) d 1.0)) - 1.0)) + (or (wasm-get-numeric-value :dpr) + (if use-dpr? + (let [d (.-devicePixelRatio ^js ug/window)] + ;; In workers `ug/window` is a mock without `devicePixelRatio`, + ;; so guard against nil/NaN/non-positive values. + (if (and (number? d) (pos? d)) d 1.0)) + 1.0))) (def noop-fn (constantly nil)) @@ -362,6 +389,14 @@ (def ^:const FRAME_TYPE_NONE 0) ;; This type should never "leak". (def ^:const FRAME_TYPE_PARTIAL 1) ;; A frame needs more render calls to end. (def ^:const FRAME_TYPE_FULL 2) ;; A frame was full. +(def ^:const FRAME_TYPE_VIEWPORT_READY 3) ;; Viewport presented; interest tiles may still be pending. + +(defn- needs-more-render-frames? + "True when WASM still has progressive tile work (visible or interest ring)." + [] + (or (= wasm/internal-frame-type FRAME_TYPE_PARTIAL) + (= wasm/internal-frame-type FRAME_TYPE_VIEWPORT_READY))) + (def ^:const RENDER-FLAG-SYNC-TILES 4) ;; Rebuild tile index without ending fast mode (pan/zoom pause). (defn- internal-render @@ -371,7 +406,7 @@ (internal-render timestamp wasm/internal-frame-type)) ([timestamp flags] (set! wasm/internal-frame-type (h/call wasm/internal-module "_render" timestamp flags)) - (when (= wasm/internal-frame-type FRAME_TYPE_PARTIAL) + (when (needs-more-render-frames?) (request-render "frame-type-partial")))) (defn- build-reload-payload @@ -467,18 +502,8 @@ (try (when (is-text-editor-wasm-enabled @st/state) (text-editor/text-editor-update-blink timestamp) - ;; Only repaint the overlay when this frame recomposited Target (a full - ;; frame). A partial frame is flushed but not presented — Target still - ;; shows the last presented frame with the overlay already on it — so - ;; repainting the translucent selection over it stacks another layer - ;; every progressive frame: it darkens, then snaps back when the final - ;; frame presents from the clean Backbuffer (the blink at the end of a - ;; zoom over a selection, gh-10709). - (when (not= wasm/internal-frame-type FRAME_TYPE_PARTIAL) - (text-editor/text-editor-render-overlay)) - ;; Drain editor events. Only content/layout changes need a full shape - ;; re-render; selection/style changes are already reflected by the - ;; overlay redrawn just above. + ;; The editor overlay is painted by the WASM frame composition; only + ;; content/layout changes need a full shape re-render here. (when (drain-text-editor-events!) (request-render-preserving-target "text-editor-content"))) (catch :default e @@ -583,12 +608,31 @@ (defonce shapes-loading? (atom false)) (defonce deferred-render? (atom false)) +;; Each `request-render` captures this number. `stop-progressive-render!` +;; increments it so already-scheduled rAFs become no-ops. +;; +;; Why: ViewportReady asks for another frame for the interest ring. If the +;; page changes before that frame runs, `_init` leaves an empty shapes pool +;; and `_render` panics ("Root shape not found"). Cancel alone is not enough +;; when the current `_render` itself schedules the next rAF after we cancelled. +(defonce ^:private render-seq* (atom 0)) + (defn render-pending? "True while a render has been scheduled but not yet completed — including the frames of an in-progress progressive render." [] @pending-render) +(defn- stop-progressive-render! + "Cancel the pending tile-pass rAF and invalidate any follow-ups it may schedule." + [] + (swap! render-seq* inc) + (when-let [frame-id wasm/internal-frame-id] + (timers/cancel-af! frame-id) + (set! wasm/internal-frame-id nil)) + (reset! pending-render false) + (set! wasm/internal-frame-type FRAME_TYPE_NONE)) + (defn- register-deferred-render! [] (reset! deferred-render? true)) @@ -602,19 +646,21 @@ (register-deferred-render!) (when-not @pending-render (reset! pending-render true) - (let [frame-id - (timers/raf - (fn [ts] - (reset! pending-render false) - (set! wasm/internal-frame-id nil) - (try - (render ts) - (catch :default e - ;; A failed render (e.g. a WASM panic) must not strand an - ;; active page-transition. Force ending of it so the - ;; workspace is shown without a blur. - (end-page-transition!) - (throw e)))))] + (let [seq-n @render-seq* + frame-id (timers/raf + (fn [ts] + ;; Dropped if `stop-progressive-render!` ran since we scheduled. + (when (= seq-n @render-seq*) + (reset! pending-render false) + (set! wasm/internal-frame-id nil) + (try + (render ts) + (catch :default e + ;; A failed render (e.g. a WASM panic) must not strand an + ;; active page-transition. Force ending of it so the + ;; workspace is shown without a blur. + (end-page-transition!) + (throw e))))))] (set! wasm/internal-frame-id frame-id)))))) (defn request-render-preserving-target @@ -637,12 +683,8 @@ (defn- begin-shapes-loading! [] (reset! shapes-loading? true) - (let [frame-id wasm/internal-frame-id - was-pending @pending-render] - (when frame-id - (js/cancelAnimationFrame frame-id) - (set! wasm/internal-frame-id nil)) - (reset! pending-render false) + (let [was-pending @pending-render] + (stop-progressive-render!) (reset! deferred-render? was-pending))) (defn- end-shapes-loading! @@ -679,6 +721,17 @@ (aget buffer 3))] (= result 1)))) +(defn- write-text-content! + "Push every paragraph of `content` to the current WASM text shape." + [content] + (let [paragraph-set (first (get content :children)) + paragraphs (get paragraph-set :children)] + (doseq [paragraph paragraphs + :let [spans (get paragraph :children)] + :when (seq spans)] + (let [text (apply str (map :text spans))] + (t/write-shape-text spans paragraph text))))) + (defn set-shape-text-content "This function sets shape text content and returns a stream that loads the needed fonts asynchronously" [shape-id content] @@ -693,22 +746,43 @@ (set-shape-vertical-align (get content :vertical-align)) - (let [fonts (f/get-content-fonts content) - fallback-fonts (fonts-from-text-content content true) - all-fonts (concat fonts fallback-fonts) - result (f/store-fonts all-fonts)] + (let [fonts (f/get-content-fonts content) + fallback-fonts (fonts-from-text-content content false) + all-fonts (concat fonts fallback-fonts) + result (f/store-fonts all-fonts)] + (write-text-content! content) (f/load-fallback-fonts-for-editor! fallback-fonts) (h/call wasm/internal-module "_update_shape_text_layout") result))) (defn apply-styles-to-selection "Apply style attrs to the currently selected text spans. - Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving." - [attrs] - (let [result (text-editor/apply-styles-to-selection attrs use-shape set-shape-text-content)] + Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving. + `:with-fills?` also returns the selection's `:fills`." + [styles & [opts]] + (let [result (text-editor/apply-styles-to-selection styles use-shape set-shape-text-content opts)] (request-render "apply-styles-to-selection") result)) +(defn apply-paragraph-attrs-to-selection + "Apply paragraph attrs to the paragraphs the editor selection touches. + Returns {:shape-id :content} for saving." + [attrs] + (let [result (text-editor/apply-paragraph-attrs-to-selection attrs use-shape set-shape-text-content)] + (request-render "apply-paragraph-attrs-to-selection") + result)) + +(defn apply-pending-caret-styles! + "Apply the shape's pending caret style over `range` (the just-typed text) and + clear it; returns {:shape-id :content} or nil when there is none." + [shape-id range] + (when-let [styles (text-editor/get-pending-caret-styles shape-id)] + (let [result (text-editor/apply-styles-to-range + shape-id range styles use-shape set-shape-text-content)] + (text-editor/clear-pending-caret-styles!) + (request-render "apply-pending-caret-styles") + result))) + (defn set-parent-id [id] (let [buffer (uuid/get-u32 id)] @@ -756,61 +830,8 @@ [children] (perf/begin-measure "set-shape-children") (let [children (into [] (filter uuid?) children)] - (case (count children) - 0 + (if (empty? children) (h/call wasm/internal-module "_set_children_0") - - 1 - (let [[c1] children - c1 (uuid/get-u32 c1)] - (h/call wasm/internal-module "_set_children_1" - (aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3))) - - 2 - (let [[c1 c2] children - c1 (uuid/get-u32 c1) - c2 (uuid/get-u32 c2)] - (h/call wasm/internal-module "_set_children_2" - (aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3) - (aget c2 0) (aget c2 1) (aget c2 2) (aget c2 3))) - - 3 - (let [[c1 c2 c3] children - c1 (uuid/get-u32 c1) - c2 (uuid/get-u32 c2) - c3 (uuid/get-u32 c3)] - (h/call wasm/internal-module "_set_children_3" - (aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3) - (aget c2 0) (aget c2 1) (aget c2 2) (aget c2 3) - (aget c3 0) (aget c3 1) (aget c3 2) (aget c3 3))) - - 4 - (let [[c1 c2 c3 c4] children - c1 (uuid/get-u32 c1) - c2 (uuid/get-u32 c2) - c3 (uuid/get-u32 c3) - c4 (uuid/get-u32 c4)] - (h/call wasm/internal-module "_set_children_4" - (aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3) - (aget c2 0) (aget c2 1) (aget c2 2) (aget c2 3) - (aget c3 0) (aget c3 1) (aget c3 2) (aget c3 3) - (aget c4 0) (aget c4 1) (aget c4 2) (aget c4 3))) - - 5 - (let [[c1 c2 c3 c4 c5] children - c1 (uuid/get-u32 c1) - c2 (uuid/get-u32 c2) - c3 (uuid/get-u32 c3) - c4 (uuid/get-u32 c4) - c5 (uuid/get-u32 c5)] - (h/call wasm/internal-module "_set_children_5" - (aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3) - (aget c2 0) (aget c2 1) (aget c2 2) (aget c2 3) - (aget c3 0) (aget c3 1) (aget c3 2) (aget c3 3) - (aget c4 0) (aget c4 1) (aget c4 2) (aget c4 3) - (aget c5 0) (aget c5 1) (aget c5 2) (aget c5 3))) - - ;; Dynamic call for children > 5 (let [heap (mem/get-heap-u32) size (mem/get-alloc-size children UUID-U8-SIZE) offset (mem/alloc->offset-32 size)] @@ -953,37 +974,56 @@ (map #(process-fill-image shape-id % thumbnail?)))))) (defn set-shape-fills - [shape-id fills thumbnail?] - ;; Record write is shared with the headless exporter; the image fetch below is - ;; browser-only (WebGL textures). - (when-let [fills (props/write-shape-fills! fills)] - (keep (fn [id] - (let [buffer (uuid/get-u32 id) - cached-image? (h/call wasm/internal-module "_is_image_cached" - (aget buffer 0) - (aget buffer 1) - (aget buffer 2) - (aget buffer 3) - thumbnail?)] - (when (zero? cached-image?) - (fetch-image shape-id id thumbnail?)))) - (types.fills/get-image-ids fills)))) + "Writes fill records (unless `write?` is false) and returns pending image + fetches. When fills were already uploaded in `_set_shapes_batch`, pass + `write?` false so only image fetches remain." + ([shape-id fills thumbnail?] + (set-shape-fills shape-id fills thumbnail? true)) + ([shape-id fills thumbnail? write?] + (let [fills (if write? + (props/write-shape-fills! fills) + (when (seq fills) + (types.fills/coerce fills)))] + (when fills + (keep (fn [id] + (let [buffer (uuid/get-u32 id) + cached-image? (h/call wasm/internal-module "_is_image_cached" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + thumbnail?)] + (when (zero? cached-image?) + (fetch-image shape-id id thumbnail?)))) + (types.fills/get-image-ids fills)))))) + +(defn- stroke-image-ids + [strokes] + (into [] + (comp (remove :hidden) + (keep #(get-in % [:stroke-image :id]))) + (or strokes []))) (defn set-shape-strokes - [shape-id strokes thumbnail?] - ;; Record write is shared with the headless exporter; the image fetch below is - ;; browser-only (WebGL textures). - (keep (fn [image-id] - (let [buffer (uuid/get-u32 image-id) - cached-image? (h/call wasm/internal-module "_is_image_cached" - (aget buffer 0) - (aget buffer 1) - (aget buffer 2) - (aget buffer 3) - thumbnail?)] - (when (zero? cached-image?) - (fetch-image shape-id image-id thumbnail?)))) - (props/write-shape-strokes! strokes))) + "Writes stroke records (unless `write?` is false) and returns pending image + fetches for stroke image fills." + ([shape-id strokes thumbnail?] + (set-shape-strokes shape-id strokes thumbnail? true)) + ([shape-id strokes thumbnail? write?] + (let [image-ids (if write? + (props/write-shape-strokes! strokes) + (stroke-image-ids strokes))] + (keep (fn [image-id] + (let [buffer (uuid/get-u32 image-id) + cached-image? (h/call wasm/internal-module "_is_image_cached" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + thumbnail?)] + (when (zero? cached-image?) + (fetch-image shape-id image-id thumbnail?)))) + image-ids)))) (defn set-shape-svg-attrs [attrs] @@ -1285,8 +1325,8 @@ langs) (let [text (apply str (map :text spans)) - emoji? (if emoji? emoji? (t/contains-emoji? text)) - langs (t/collect-used-languages langs text)] + emoji? (if emoji? emoji? (cfnt/contains-emoji? text)) + langs (cfnt/collect-used-languages langs text)] ;; FIXME: this should probably be somewhere else (when fallback-fonts-only? (t/write-shape-text spans paragraph text)) @@ -1297,8 +1337,8 @@ (let [updated-fonts (-> #{} - (cond-> ^boolean emoji? (f/add-emoji-font)) - (f/add-noto-fonts langs)) + (cond-> ^boolean emoji? (cfnt/add-emoji-font)) + (cfnt/add-noto-fonts langs)) fallback-fonts (filter #(get % :is-fallback) updated-fonts)] (if fallback-fonts-only? updated-fonts fallback-fonts)))))) @@ -1346,7 +1386,9 @@ (defn view-interaction-start! [] - (when (and (initialized?) (not @view-interaction-active?)) + (when (and (initialized?) + (not @page-transition?) + (not @view-interaction-active?)) (h/call wasm/internal-module "_set_view_start") (reset! view-interaction-active? true))) @@ -1364,28 +1406,6 @@ (let [local (get @st/state :workspace-local)] (or (:panning local) (:zooming local)))) -(defn- render-text-editor-overlay-if-active! - "Redraw the editor caret/selection straight onto the current Target frame when - an editor is active (no-op otherwise). Used after the direct `_render_from_cache` - / `internal-render` calls of a view interaction, which bypass the rAF `render` - loop that normally repaints the overlay. Without it the selection blinks out - for the duration of a pan/zoom gesture over a text shape (gh-10709)." - [] - (when (is-text-editor-wasm-enabled @st/state) - (text-editor/text-editor-render-overlay))) - -(defn- render-text-editor-overlay-after-frame! - "Repaint the overlay after a direct `internal-render`, but only when that - render recomposited Target (a full frame). A partial frame is only flushed — - Target keeps the last presented frame with the overlay already on it — so - repainting the translucent selection then stacks another layer and it visibly - darkens across the progressive frames before snapping back on the final - present (the blink at the end of a zoom over a selection, gh-10709). The - final full frame's own repaint keeps the overlay in place." - [] - (when (not= wasm/internal-frame-type FRAME_TYPE_PARTIAL) - (render-text-editor-overlay-if-active!))) - (defn finalize-view-interaction! "Ends an in-progress pan/zoom view interaction and triggers a full-quality render. No-ops when no view interaction is active. @@ -1404,12 +1424,7 @@ ;; this implicitly (`zoom_changed`); this extends it to pan/resize-triggered ;; ends (e.g. selecting a shape opens the options panel and resizes the ;; viewport), which previously blanked. - (internal-render 0 RENDER-FLAG-SYNC-TILES) - ;; The direct render above bypasses the rAF `render` loop, so repaint the - ;; editor overlay explicitly. Only when this was a full frame: a progressive - ;; render keeps painting through the rAF loop and its partial frames must not - ;; be over-stamped (see `render-text-editor-overlay-after-frame!`). - (render-text-editor-overlay-after-frame!))) + (internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES))) (def render-finish (letfn [(do-render [] @@ -1418,33 +1433,26 @@ (when (initialized?) (if (view-gesture-active?) ;; Pan/zoom pause: render without ending the interaction. - (do - (internal-render 0 RENDER-FLAG-SYNC-TILES) - (render-text-editor-overlay-after-frame!)) + (internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES) (finalize-view-interaction!))))] (fns/debounce do-render DEBOUNCE_DELAY_MS))) (defn set-view-box [zoom vbox] - (when (initialized?) - (perf/begin-measure "set-view-box") - (view-interaction-start!) - (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) - (perf/end-measure "set-view-box") + ;; Frozen during page transition: tile atlas is empty/incomplete and + ;; render_from_cache would present a blank workspace. + (if @page-transition? + (reset! viewport-dirty-during-transition? true) + (when (initialized?) + (perf/begin-measure "set-view-box") + (view-interaction-start!) + (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) + (perf/end-measure "set-view-box") - (perf/begin-measure "render-from-cache") - (h/call wasm/internal-module "_render_from_cache" 0) - ;; Keep the text-editor caret/selection glued to the shapes while the view - ;; changes. `_render_from_cache` re-composites shapes + UI at the new viewbox - ;; but omits the editor overlay, so without this the selection would vanish for - ;; the whole pan/zoom gesture and only flash back when the debounced full - ;; render lands — the blink seen when zooming in/out over a selection at high - ;; zoom (gh-10709). `_text_editor_render_overlay` draws straight onto the - ;; freshly composited Target (no Backbuffer re-compose) and no-ops when no - ;; editor is active. - (render-text-editor-overlay-if-active!) - (render-finish) - (perf/end-measure "render-from-cache"))) + (perf/begin-measure "render-from-cache") + (h/call wasm/internal-module "_render_from_cache" 0) + (render-finish) + (perf/end-measure "render-from-cache")))) (defn sync-workspace-local-viewport! "Pushes `[:workspace-local :zoom]` and `:vbox` into WASM." @@ -1462,46 +1470,127 @@ [content] (or content (tc/v2-default-text-content))) +(def ^:private empty-text-font-state + {:font-index {} :pending-faces #{}}) + +(defn- text-layout-fonts + "Content and fallback faces (emoji, Noto, ...) that `set-shape-text-content` + uploads, must match that path for pending-face tracking." + [content] + (into #{} (concat (f/get-content-fonts content) + (fonts-from-text-content content false)))) + +(defn- text-font-face-keys-state + "All font-face keys for a text content, and the subset not WASM-ready yet." + [content] + (reduce + (fn [acc font] + (let [font-data (f/make-font-data font) + key (f/font-data-key font-data) + pending? (not (f/font-ready? font-data))] + (-> acc + (update :font-face-keys conj key) + (cond-> pending? + (update :pending-font-face-keys conj key))))) + {:font-face-keys #{} :pending-font-face-keys #{}} + (text-layout-fonts content))) + +(defn- acc-text-font-state + [{:keys [font-index pending-faces]} id font-face-keys pending-font-face-keys] + {:font-index (reduce (fn [idx face] + (update idx face (fnil conj #{}) id)) + font-index + (or font-face-keys #{})) + :pending-faces (into (or pending-faces #{}) + (or pending-font-face-keys #{}))}) + +(defn text-font-state-for-shape + "Build the font-face index for a single text shape (incremental updates)." + [shape] + (if (cfh/text-shape? shape) + (let [content (ensure-text-content (:content shape)) + {:keys [font-face-keys pending-font-face-keys]} + (text-font-face-keys-state content)] + (acc-text-font-state empty-text-font-state + (:id shape) + font-face-keys + pending-font-face-keys)) + empty-text-font-state)) + +(defn- shape-ids-for-pending-fonts + [{:keys [font-index pending-faces]}] + (when (seq pending-faces) + (into #{} + (mapcat #(get font-index % [])) + pending-faces))) + +(defn- set-object-host-attrs + "Host-specific attrs after structural upload (text/svg-raw/grid; optionally + fills/strokes). When `skip-layout?` is true, flex+layout-item were already in + the batch; only grid tracks/cells are applied here. When + `skip-fills-strokes?` is true, fill/stroke records were already in the batch; + only image fetches remain. + + Always `use-shape` first: after a multi-shape batch the WASM current shape is + the last record in the chunk, not this shape." + [shape skip-layout? & {:keys [skip-fills-strokes?] :or {skip-fills-strokes? false}}] + (let [id (dm/get-prop shape :id) + type (dm/get-prop shape :type) + fills (get shape :fills) + strokes (if (= type :group) [] (get shape :strokes)) + content (let [content (get shape :content)] + (if (= type :text) + (ensure-text-content content) + content)) + write-fills-strokes? (not skip-fills-strokes?) + needs-current? (or write-fills-strokes? + (= type :text) + (and (some? content) (= type :svg-raw)) + (if skip-layout? + (ctl/grid-layout? shape) + true))] + + (when needs-current? + (use-shape id)) + + (when (and (some? content) (= type :svg-raw)) + (set-shape-svg-raw-content (get-static-markup shape))) + + (if skip-layout? + (when (ctl/grid-layout? shape) + (set-grid-layout shape)) + (do (set-shape-layout shape) + (set-layout-data shape))) + + (let [is-text? (= type :text) + {:keys [font-face-keys pending-font-face-keys]} + (when is-text? (text-font-face-keys-state content)) + text-content-pending (when is-text? (set-shape-text-content id content)) + pending-thumbnails (into [] (concat + text-content-pending + (when is-text? (set-shape-text-images id content true)) + (set-shape-fills id fills true write-fills-strokes?) + (set-shape-strokes id strokes true write-fills-strokes?))) + pending-full (into [] (concat + (when is-text? (set-shape-text-images id content false)) + (set-shape-fills id fills false write-fills-strokes?) + (set-shape-strokes id strokes false write-fills-strokes?)))] + {:thumbnails pending-thumbnails + :full pending-full + :font-face-keys (or font-face-keys #{}) + :pending-font-face-keys (or pending-font-face-keys #{})}))) + (defn set-object [shape] (if-not (and shape (wasm/live?)) - {:thumbnails [] :full [] :font-pending-ids []} + {:thumbnails [] :full [] :font-face-keys #{} :pending-font-face-keys #{}} (do (perf/begin-measure "set-object") - (let [shape (svg-filters/apply-svg-derived shape) - id (dm/get-prop shape :id) - type (dm/get-prop shape :type) - - fills (get shape :fills) - strokes (if (= type :group) - [] (get shape :strokes)) - content (let [content (get shape :content)] - (if (= type :text) - (ensure-text-content content) - content))] - + (let [shape (svg-filters/apply-svg-derived shape)] (serialize-shape/serialize-shape! shape) - - ;; Browser-only: svg-raw markup (needs React) + workspace layout. - (when (and (some? content) (= type :svg-raw)) - (set-shape-svg-raw-content (get-static-markup shape))) - (set-shape-layout shape) - (set-layout-data shape) - (let [is-text? (= type :text) - text-content-pending (when is-text? (set-shape-text-content id content)) - pending-thumbnails (into [] (concat - text-content-pending - (when is-text? (set-shape-text-images id content true)) - (set-shape-fills id fills true) - (set-shape-strokes id strokes true))) - pending-full (into [] (concat - (when is-text? (set-shape-text-images id content false)) - (set-shape-fills id fills false) - (set-shape-strokes id strokes false)))] + (let [result (set-object-host-attrs shape false)] (perf/end-measure "set-object") - {:thumbnails pending-thumbnails - :full pending-full - :font-pending-ids (if (some :callback text-content-pending) [id] [])}))))) + result))))) (defn- update-text-layouts "Synchronously update text layouts for all shapes and send rect updates @@ -1527,6 +1616,29 @@ :auto-height (not (mth/close? height (:height selrect) 0.1)) false))) +(defonce ^:private pending-stale-selrect-ids (atom #{})) +(defonce ^:private stale-selrect-sync-token (atom 0)) + +(defn- flush-stale-selrect-sync! + [] + (when-let [ids (seq (first (reset-vals! pending-stale-selrect-ids #{})))] + (st/emit! (ptk/data-event ::stale-text-selrects {:ids (vec ids)})))) + +(defn- schedule-stale-selrect-sync! + "Coalesce stale-selrect emissions and defer until the first viewport tile + pass completes, then run on idle so page load can paint first." + [stale-ids] + (swap! pending-stale-selrect-ids into stale-ids) + (let [token (swap! stale-selrect-sync-token inc) + flush-on-idle! + (fn [] + (when (= token @stale-selrect-sync-token) + (timers/schedule-on-idle + (fn [] + (when (= token @stale-selrect-sync-token) + (flush-stale-selrect-sync!))))))] + (listen-tiles-render-complete-once! flush-on-idle!))) + (defn- sync-stale-text-selrects! "Emit the ids of auto-grow text shapes whose selrect no longer matches the measured layout, so the workspace resizes them (data-event instead of a @@ -1540,24 +1652,26 @@ (map :id)) shapes)] (when (seq stale-ids) - (st/emit! (ptk/data-event ::stale-text-selrects {:ids stale-ids}))))) + (schedule-stale-selrect-sync! stale-ids)))) (defn- relayout-after-fonts! "Relayout text shapes once their pending fonts have resolved. Font fetches - are deduped per URL and storing a font does not invalidate cached layouts, - so every text shape (not only the fetch triggers in `font-pending-ids`) - needs a forced relayout; then re-sync selrects that drifted." - [shapes font-pending-ids] - (let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)] + are deduped per URL, so only shapes that use a not-yet-ready face at upload + time need a forced relayout; then re-sync selrects that drifted for those + shapes only." + [shapes text-font-state] + (let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes) + affected-ids (or (shape-ids-for-pending-fonts text-font-state) #{}) + shapes-by-id (d/index-by :id shapes)] (when (seq text-ids) - (if (seq font-pending-ids) - (do - (force-update-text-layouts text-ids) - (sync-stale-text-selrects! shapes)) + (if (seq affected-ids) + (let [affected-shapes (into [] (keep shapes-by-id) affected-ids)] + (force-update-text-layouts affected-ids) + (sync-stale-text-selrects! affected-shapes)) (update-text-layouts text-ids))))) (defn process-pending - [shapes thumbnails full font-pending-ids on-complete] + [shapes thumbnails full text-font-state on-complete] (let [pending-thumbnails (d/index-by :key :callback thumbnails) @@ -1580,18 +1694,24 @@ (rx/reduce conj []) (rx/catch #(rx/empty)))) (rx/subs! - (fn [_] - (relayout-after-fonts! shapes font-pending-ids) - (request-render "images-loaded")) noop-fn - (fn [] (when (fn? on-complete) (on-complete))))) + noop-fn + (fn [] + (relayout-after-fonts! shapes text-font-state) + (request-render "images-loaded") + (when (fn? on-complete) (on-complete))))) ;; No pending images — complete immediately. (when on-complete (on-complete))))) (defn process-object [shape] - (let [{:keys [thumbnails full font-pending-ids]} (set-object shape)] - (process-pending [shape] thumbnails full font-pending-ids noop-fn))) + (let [{:keys [thumbnails full font-face-keys pending-font-face-keys]} + (set-object shape) + text-font-state (acc-text-font-state empty-text-font-state + (:id shape) + font-face-keys + pending-font-face-keys)] + (process-pending [shape] thumbnails full text-font-state noop-fn))) (defn process-objects "Like process-object but for multiple shapes at once. Accumulates all @@ -1600,44 +1720,78 @@ just the first shape that triggered the fetch." [shapes] (let [total-shapes (count shapes) - {:keys [thumbnails full font-pending-ids]} - (loop [index 0 thumbnails-acc (transient []) full-acc (transient []) font-acc (transient [])] + {:keys [thumbnails full text-font-state]} + (loop [index 0 + thumbnails-acc (transient []) + full-acc (transient []) + font-state-acc empty-text-font-state] (if (< index total-shapes) (let [shape (nth shapes index) - {:keys [thumbnails full font-pending-ids]} (set-object shape)] + {:keys [thumbnails full font-face-keys pending-font-face-keys]} + (set-object shape)] (recur (inc index) (reduce conj! thumbnails-acc thumbnails) (reduce conj! full-acc full) - (reduce conj! font-acc font-pending-ids))) + (acc-text-font-state font-state-acc + (:id shape) + font-face-keys + pending-font-face-keys))) {:thumbnails (persistent! thumbnails-acc) :full (persistent! full-acc) - :font-pending-ids (persistent! font-acc)}))] - (process-pending shapes thumbnails full font-pending-ids noop-fn))) + :text-font-state font-state-acc}))] + (process-pending shapes thumbnails full text-font-state noop-fn))) + +(def ^:private ^:const BATCH_MAX_SHAPES 512) (defn- process-shapes-chunk - "Process shapes starting at `start-index` until the time budget is exhausted. - Returns {:thumbnails [...] :full [...] :font-pending-ids [...] :next-index n}" - [shapes start-index thumbnails-acc full-acc font-pending-acc] - (let [total (count shapes) - deadline (+ (js/performance.now) CHUNK_TIME_BUDGET_MS)] - (loop [index start-index + "Process up to `BATCH_MAX_SHAPES` shapes starting at `start-index`. + + Structural attrs are uploaded in one `_set_shapes_batch` FFI per chunk; + host-specific attrs (fills/strokes/text/grid/path) stay per-shape. + + Returns {:thumbnails [...] :full [...] :text-font-state {...} :next-index n}" + [shapes start-index thumbnails-acc full-acc text-font-state-acc] + (let [total (count shapes) + end-index (min total (+ start-index BATCH_MAX_SHAPES)) + chunk (into [] (subvec (if (vector? shapes) shapes (vec shapes)) + start-index end-index)) + prepared (mapv svg-filters/apply-svg-derived chunk)] + + ;; One multi-shape structural upload (base+children+blur+shadows+flex+item+fills+strokes). + (when (seq prepared) + (upload/flush-shapes-batch! prepared {:include-layout? true + :include-fills-strokes? true})) + + ;; Path + svg-attrs still need the legacy per-shape path (variable/large). + (doseq [shape prepared] + (let [id (dm/get-prop shape :id) + type (dm/get-prop shape :type)] + (when (or (some? (get shape :svg-attrs)) + (and (contains? #{:path :bool} type) (some? (get shape :content)))) + (use-shape id) + (when (some? (get shape :svg-attrs)) + (props/set-shape-svg-attrs (get shape :svg-attrs))) + (when (and (contains? #{:path :bool} type) (some? (get shape :content))) + (props/set-shape-path-content (get shape :content)))))) + + (loop [xs prepared t-acc (transient thumbnails-acc) f-acc (transient full-acc) - fp-acc (transient font-pending-acc)] - (if (and (< index total) - ;; Check performance.now every 8 shapes to reduce overhead - (or (pos? (bit-and (- index start-index) 7)) - (<= (js/performance.now) deadline))) - (let [shape (nth shapes index) - {:keys [thumbnails full font-pending-ids]} (set-object shape)] - (recur (inc index) + font-state-acc text-font-state-acc] + (if-let [shape (first xs)] + (let [{:keys [thumbnails full font-face-keys pending-font-face-keys]} + (set-object-host-attrs shape true :skip-fills-strokes? true)] + (recur (next xs) (reduce conj! t-acc thumbnails) (reduce conj! f-acc full) - (reduce conj! fp-acc font-pending-ids))) + (acc-text-font-state font-state-acc + (:id shape) + font-face-keys + pending-font-face-keys))) {:thumbnails (persistent! t-acc) :full (persistent! f-acc) - :font-pending-ids (persistent! fp-acc) - :next-index index})))) + :text-font-state font-state-acc + :next-index end-index})))) (defn- set-objects-async "Asynchronously process shapes in time-budgeted chunks, yielding to the @@ -1647,16 +1801,16 @@ (let [total-shapes (count shapes)] (p/create (fn [resolve _reject] - (letfn [(process-next-chunk [index thumbnails-acc full-acc font-pending-acc] + (letfn [(process-next-chunk [index thumbnails-acc full-acc text-font-state-acc] (if (< index total-shapes) ;; Process one time-budgeted chunk - (let [{:keys [thumbnails full font-pending-ids next-index]} + (let [{:keys [thumbnails full text-font-state next-index]} (process-shapes-chunk shapes index - thumbnails-acc full-acc font-pending-acc)] + thumbnails-acc full-acc text-font-state-acc)] ;; Yield to browser, then continue with next chunk (-> (yield-to-browser) (p/then (fn [_] - (process-next-chunk next-index thumbnails full font-pending-ids))))) + (process-next-chunk next-index thumbnails full text-font-state))))) ;; All chunks done - finalize (do (perf/end-measure "set-objects") @@ -1707,12 +1861,12 @@ (if (fn? callback) (callback) (rx/empty)))) (rx/reduce conj []))) (rx/subs! - (fn [_] - (relayout-after-fonts! shapes font-pending-acc) - (request-render "images-loaded")) noop-fn - noop-fn)))))))))] - (process-next-chunk 0 [] [] [])))))) + noop-fn + (fn [] + (relayout-after-fonts! shapes text-font-state-acc) + (request-render "images-loaded")))))))))))] + (process-next-chunk 0 [] [] empty-text-font-state)))))) ;; This is a version of process-pending that doesn't have sideffects @@ -1764,32 +1918,53 @@ (defn- set-objects-sync "Synchronously process all shapes (for small shape counts)." [shapes render-callback on-shapes-ready] - (let [total-shapes (count shapes) - {:keys [thumbnails full font-pending-ids]} - (loop [index 0 thumbnails-acc (transient []) full-acc (transient []) font-acc (transient [])] - (if (< index total-shapes) - (let [shape (nth shapes index) - {:keys [thumbnails full font-pending-ids]} (set-object shape)] - (recur (inc index) - (reduce conj! thumbnails-acc thumbnails) - (reduce conj! full-acc full) - (reduce conj! font-acc font-pending-ids))) - {:thumbnails (persistent! thumbnails-acc) - :full (persistent! full-acc) - :font-pending-ids (persistent! font-acc)}))] - (perf/end-measure "set-objects") - (when on-shapes-ready (on-shapes-ready)) - (when (wasm/live?) - ;; Rebuild the tile index so _render knows which shapes - ;; map to which tiles after a page switch. - (h/call wasm/internal-module "_set_view_end") - (reset! view-interaction-active? false) - (process-pending shapes thumbnails full font-pending-ids - (fn [] - (if render-callback - (render-callback) - (request-render "set-objects-sync-complete")) - (ug/dispatch! (ug/event "penpot:wasm:set-objects"))))))) + (let [prepared (mapv svg-filters/apply-svg-derived shapes)] + (when (seq prepared) + (upload/flush-shapes-batch! prepared {:include-layout? true + :include-fills-strokes? true})) + (doseq [shape prepared] + (let [id (dm/get-prop shape :id) + type (dm/get-prop shape :type)] + (when (or (some? (get shape :svg-attrs)) + (and (contains? #{:path :bool} type) (some? (get shape :content)))) + (use-shape id) + (when (some? (get shape :svg-attrs)) + (props/set-shape-svg-attrs (get shape :svg-attrs))) + (when (and (contains? #{:path :bool} type) (some? (get shape :content))) + (props/set-shape-path-content (get shape :content)))))) + (let [total-shapes (count prepared) + {:keys [thumbnails full text-font-state]} + (loop [index 0 + thumbnails-acc (transient []) + full-acc (transient []) + font-state-acc empty-text-font-state] + (if (< index total-shapes) + (let [shape (nth prepared index) + {:keys [thumbnails full font-face-keys pending-font-face-keys]} + (set-object-host-attrs shape true :skip-fills-strokes? true)] + (recur (inc index) + (reduce conj! thumbnails-acc thumbnails) + (reduce conj! full-acc full) + (acc-text-font-state font-state-acc + (:id shape) + font-face-keys + pending-font-face-keys))) + {:thumbnails (persistent! thumbnails-acc) + :full (persistent! full-acc) + :text-font-state font-state-acc}))] + (perf/end-measure "set-objects") + (when on-shapes-ready (on-shapes-ready)) + (when (wasm/live?) + ;; Rebuild the tile index so _render knows which shapes + ;; map to which tiles after a page switch. + (h/call wasm/internal-module "_set_view_end") + (reset! view-interaction-active? false) + (process-pending shapes thumbnails full text-font-state + (fn [] + (if render-callback + (render-callback) + (request-render "set-objects-sync-complete")) + (ug/dispatch! (ug/event "penpot:wasm:set-objects")))))))) (defn- shapes-in-tree-order "Returns shapes sorted in tree order (parents before children). @@ -2043,6 +2218,8 @@ (let [rgba (when background (sr-clr/hex->u32argb background background-opacity)) total-shapes (count (vals base-objects))] + ;; Stop Partial/ViewportReady follow-ups before we clear the shapes pool. + (stop-progressive-render!) (when rgba (h/call wasm/internal-module "_set_canvas_background" rgba)) (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) (h/call wasm/internal-module "_init_shapes_pool" total-shapes) @@ -2140,26 +2317,34 @@ (when (wasm/live?) (h/call wasm/internal-module "_set_render_options" (debug-flags) new-dpr))) +(def ^:private max-surface-size + ;; Must match `gpu_state::MAX_SURFACE_SIZE`. + 8192) + +(defn- clamp-physical-size + "Clamp physical pixel dimensions before assigning `canvas.width/height`. + Rust `resize` applies the same cap and syncs the effective DPR from the + real drawing buffer." + [w h] + (let [w (mth/max 1 w) + h (mth/max 1 h) + scale (mth/min 1 (/ max-surface-size w) (/ max-surface-size h))] + [(mth/max 1 (mth/floor (* scale w))) + (mth/max 1 (mth/floor (* scale h)))])) + (defn resize-offscreen-canvas! "Resize a persistent OffscreenCanvas to new physical-pixel dimensions and update the WASM render surfaces accordingly (via `_resize_viewbox`). The design state (shape pool) is preserved so `set-objects` is not needed again." [canvas new-physical-w new-physical-h] (when (wasm/live?) - (let [dpr (get-dpr)] - (set! (.-width canvas) new-physical-w) - (set! (.-height canvas) new-physical-h) + (let [dpr (get-dpr) + [pw ph] (clamp-physical-size new-physical-w new-physical-h)] + (set! (.-width canvas) pw) + (set! (.-height canvas) ph) (set-render-options! dpr) (resize-viewbox (/ new-physical-w dpr) (/ new-physical-h dpr))))) -(defn- wasm-get-numeric-value - [name] - (when-let [raw (let [p (rt/get-params @st/state)] - (get p name))] - (let [n (if (string? raw) (js/parseFloat raw) raw)] - (when (and (number? n) (not (js/isNaN n)) (pos? n)) - n)))) - (defn- wasm-set-param-from-route-params-if-present [param-name] (when-let [value (wasm-get-numeric-value param-name)] @@ -2191,9 +2376,14 @@ (resize-canvas! canvas (get-dpr))) ([canvas new-dpr] (when (wasm/live?) - (let [[css-w css-h] (canvas-css-size canvas new-dpr)] - (set! (.-width ^js canvas) (* new-dpr css-w)) - (set! (.-height ^js canvas) (* new-dpr css-h)) + (let [[css-w css-h] (canvas-css-size canvas new-dpr) + css-w (mth/max 1 css-w) + css-h (mth/max 1 css-h) + [phys-w phys-h] (clamp-physical-size + (mth/floor (* css-w new-dpr)) + (mth/floor (* css-h new-dpr)))] + (set! (.-width ^js canvas) phys-w) + (set! (.-height ^js canvas) phys-h) (set-render-options! new-dpr) (resize-viewbox css-w css-h))))) @@ -2312,12 +2502,9 @@ (set! wasm/context-initialized? false) - ;; Cancel any pending animation frame to prevent race conditions. - (when wasm/internal-frame-id - (timers/cancel-af! wasm/internal-frame-id)) + (stop-progressive-render!) - ;; Reset render flags to prevent new renders from being scheduled. - (reset! pending-render false) + ;; Reset remaining render flags so teardown cannot schedule work. (reset! shapes-loading? false) (reset! deferred-render? false) (reset! view-interaction-active? false) @@ -2775,6 +2962,25 @@ (mem/free) result))) +(defn render-shape-svg + [shape-id scale] + (when (initialized?) + (let [buffer (uuid/get-u32 shape-id) + offset + (h/call wasm/internal-module "_render_shape_svg" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + scale) + + heap (mem/get-heap-u8) + heapu32 (mem/get-heap-u32) + length (aget heapu32 (mem/->offset-32 offset)) + result (dr/read-image-bytes heap (+ offset 4) length)] + (mem/free) + result))) + (defn init-wasm-module [module] (let [default-fn (unchecked-get module "default") diff --git a/frontend/src/app/render_wasm/api/enums.cljs b/frontend/src/app/render_wasm/api/enums.cljs new file mode 100644 index 0000000000..91ad1325d3 --- /dev/null +++ b/frontend/src/app/render_wasm/api/enums.cljs @@ -0,0 +1,19 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.render-wasm.api.enums + "Binds this build's generated enums into the shared bridge. + + `shared.js` is emitted next to this file by `render-wasm/build frontend` and + is not committed. Requiring this namespace is what makes + `app.common.render-wasm.wasm/serializers` usable." + (:require + ["./shared.js" :as shared] + [app.common.render-wasm.wasm :as wasm]) + (:require-macros + [app.common.render-wasm.enums :as enums])) + +(wasm/init-serializers! (enums/serializers shared)) diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index c3d5a32a35..0b438c5e20 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -2,21 +2,21 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.api.fonts (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.fonts :as cfnt] [app.common.logging :as log] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.wasm :as wasm] [app.common.types.text :as txt] [app.common.uuid :as uuid] [app.config :as cf] [app.main.fonts :as fonts] [app.main.store :as st] - [app.render-wasm.fallback-fonts :as fbf] - [app.render-wasm.helpers :as h] - [app.render-wasm.wasm :as wasm] [app.util.http :as http] [app.util.timers :as tm] [beicon.v2.core :as rx] @@ -30,42 +30,34 @@ (def ^:private custom-fonts (l/derived :fonts st/state)) -;; Emits the font-id of every font whose glyphs wasm can already shape and -;; measure with. The browser-side loading of `app.main.fonts` is a separate -;; signal: it only says the DOM can render the font. +;; Emits every font face that WASM can measure. (defonce font-stored-stream (rx/subject)) +;; Emits failed font faces so layout can fall back. +(defonce font-storage-failed-stream (rx/subject)) + +;; Stores faces that currently use WASM fallbacks. +(defonce ^:private failed-font-data-keys (atom #{})) + +(defn font-data-key + "Returns the identity WASM uses to distinguish stored faces in one family." + [font-data] + (select-keys font-data [:font-id :weight :style :emoji?])) + +(defn- clear-font-storage-failure! + [font-data] + (swap! failed-font-data-keys disj (font-data-key font-data))) + +(defn- report-font-storage-failed! + [font-data] + (let [key (font-data-key font-data)] + (swap! failed-font-data-keys conj key) + (rx/push! font-storage-failed-stream key))) + (def ^:private default-font-size 14) (def ^:private default-line-height 1.2) (def ^:private default-letter-spacing 0.0) -(defn- google-font-id->uuid - "Returns the UUID for a Google Font ID. Uses uuid/zero as fallback when the - font is not found in fontsdb. uuid/zero maps to the default font (Source - Sans Pro) in WASM. - A font id may not exist for different reasons: - - the gfonts.json catalog was updated and fonts were renamed or removed, - - the file was imported from another Penpot instance with different fonts, - ..." - [font-id] - (let [font (fonts/get-font-data font-id) - result (:uuid font)] - (or result uuid/zero))) - -(defn- custom-font-id->uuid - [font-id] - (uuid/uuid (subs font-id (inc (str/index-of font-id "-"))))) - -(defn- font-backend - [font-id] - (cond - (str/starts-with? font-id "gfont-") - :google - (str/starts-with? font-id "custom-") - :custom - :else - :builtin)) - (defn- font-db-data [font-id font-variant-id font-weight-fallback font-style-fallback] (let [font (fonts/get-font-data font-id) @@ -75,15 +67,6 @@ variant closest-variant))) -(defn- font-id->uuid [font-id] - (case (font-backend font-id) - :google - (google-font-id->uuid font-id) - :custom - (custom-font-id->uuid font-id) - :builtin - uuid/zero)) - (defn uuid->font-id [font-uuid] (if (= font-uuid uuid/zero) @@ -100,11 +83,11 @@ "regular"))) (defn ^:private font-id->asset-id [font-id font-variant-id font-weight font-style] - (case (font-backend font-id) + (case (cfnt/font-id->backend font-id) :google font-id :custom - (let [font-uuid (custom-font-id->uuid font-id) + (let [font-uuid (cfnt/font-id->uuid font-id) matching-font (some (fn [[_ font]] (and (= (:font-id font) font-uuid) (= (str (:font-weight font)) (str font-weight)) @@ -138,8 +121,28 @@ (aget shape-id-buffer 3))))) ;; IMPORTANT: Only TTF fonts can be stored. +(defn- store-font-url + [font-data font-url] + (when (and (wasm/live?) (some? font-url) (not (str/blank? font-url))) + (let [font-id-buffer (:family-id-buffer font-data) + encoder (js/TextEncoder.) + encoded (.encode encoder font-url) + size (.-byteLength encoded) + ptr (h/call wasm/internal-module "_alloc_bytes" size) + heap (gobj/get ^js wasm/internal-module "HEAPU8") + mem (js/Uint8Array. (.-buffer heap) ptr size)] + (.set mem encoded) + (h/call wasm/internal-module "_store_font_url" + (aget font-id-buffer 0) + (aget font-id-buffer 1) + (aget font-id-buffer 2) + (aget font-id-buffer 3) + (:weight font-data) + (:style font-data)) + true))) + (defn- store-font-buffer - [font-data font-array-buffer emoji? fallback?] + [font-data font-array-buffer font-url emoji? fallback?] (when (wasm/live?) (let [font-id-buffer (:family-id-buffer font-data) size (.-byteLength font-array-buffer) @@ -157,50 +160,103 @@ (:style font-data) emoji? fallback?) + (store-font-url font-data font-url) + (clear-font-storage-failure! font-data) ;; Reported after the store call: subscribers react by measuring text. - (rx/push! font-stored-stream (:font-id font-data)) + (rx/push! font-stored-stream (font-data-key font-data)) true))) -;; Tracks fonts currently being fetched: {url -> fallback?} -;; When the same font is requested as both primary and fallback, -;; the fallback flag is upgraded to true so it gets registered -;; in WASM's fallback_fonts set. +;; Tracks every font face waiting on each shared request. (def fetching (atom {})) +(defn- register-font-fetch! + [font-url font-data emoji? fallback?] + (let [key (font-data-key font-data)] + (clear-font-storage-failure! font-data) + (swap! fetching + update-in + [font-url key] + (fn [request] + {:font-data font-data + :emoji? emoji? + :fallback? (or fallback? (:fallback? request)) + :font-url font-url})))) + +(defn- take-font-fetches! + [font-url] + (let [requests (vals (get @fetching font-url))] + (swap! fetching dissoc font-url) + requests)) + +(defn- fail-font-fetches! + [font-url cause] + (let [requests (take-font-fetches! font-url)] + (log/error :hint "Could not fetch font" + :font-url font-url + :cause cause) + (doseq [{:keys [font-data]} requests] + (report-font-storage-failed! font-data)))) + +(defn- store-font-fetch! + [body {:keys [font-data emoji? fallback? font-url]}] + (try + (let [stored? (store-font-buffer font-data body font-url emoji? fallback?)] + (when-not stored? + (report-font-storage-failed! font-data)) + stored?) + (catch :default cause + (log/error :hint "Could not store font" + :font-id (:font-id font-data) + :cause cause) + (report-font-storage-failed! font-data) + false))) + (defn- fetch-font [font-data font-url emoji? fallback?] - (if (contains? @fetching font-url) - (do (when fallback? (swap! fetching assoc font-url true)) - nil) + (cond + (nil? font-url) + ;; Fail missing font assets without sharing a nil request. (do - (swap! fetching assoc font-url fallback?) + (clear-font-storage-failure! font-data) + (tm/schedule #(report-font-storage-failed! font-data)) + nil) + + (contains? @fetching font-url) + (do + (register-font-fetch! font-url font-data emoji? fallback?) + nil) + + :else + (do + (register-font-fetch! font-url font-data emoji? fallback?) {:key font-url :callback (fn [] - (->> (http/send! {:method :get - :uri font-url - :response-type :buffer}) - (rx/map (fn [{:keys [body]}] - (let [fallback? (get @fetching font-url fallback?)] - (swap! fetching dissoc font-url) - (store-font-buffer font-data body emoji? fallback?)))) - (rx/catch (fn [cause] - (swap! fetching dissoc font-url) - (log/error :hint "Could not fetch font" - :font-url font-url - :cause cause) - (rx/empty)))))}))) + (try + (->> (http/send! {:method :get + :uri font-url + :response-type :buffer}) + (rx/map + (fn [{:keys [body]}] + (let [requests (take-font-fetches! font-url)] + (mapv (partial store-font-fetch! body) requests)))) + (rx/catch + (fn [cause] + (fail-font-fetches! font-url cause) + (rx/empty)))) + (catch :default cause + (fail-font-fetches! font-url cause) + (rx/empty))))}))) (defn- google-font-ttf-url [font-id font-variant-id font-weight font-style] (let [variant (font-db-data font-id font-variant-id font-weight font-style)] - (if-let [ttf-url (:ttf-url variant)] - (str/replace ttf-url "https://fonts.gstatic.com/s/" (u/join cf/public-uri "internal/gfonts/font/")) - nil))) + (when-let [ttf-url (:ttf-url variant)] + (cfnt/gstatic->proxy-url ttf-url (u/join cf/public-uri "internal/gfonts/font"))))) (defn- font-id->ttf-url [font-id asset-id font-variant-id font-weight font-style] - (case (font-backend font-id) + (case (cfnt/font-id->backend font-id) :google (google-font-ttf-url font-id font-variant-id font-weight font-style) :custom @@ -220,9 +276,15 @@ (:style font-data) emoji?)))) +(defn font-ready? + "Returns true when WASM can lay out with the requested face or its fallback." + [font-data] + (or (contains? @failed-font-data-keys (font-data-key font-data)) + (font-stored? font-data (:emoji? font-data)))) + (defn- store-font-id [font-data asset-id emoji? fallback?] - (when asset-id + (if asset-id (let [uri (font-id->ttf-url (:font-id font-data) asset-id (:font-variant-id font-data) @@ -234,8 +296,17 @@ (if font-stored? ;; Deferred so consumers, which subscribe after dispatching the sync ;; that lands here, are listening when an already-stored font reports. - (tm/schedule #(rx/push! font-stored-stream (:font-id font-data))) - (fetch-font font-data uri emoji? fallback?))))) + (do + (store-font-url font-data uri) + (clear-font-storage-failure! font-data) + (tm/schedule #(rx/push! font-stored-stream (font-data-key font-data)))) + (fetch-font font-data uri emoji? fallback?))) + ;; Report missing font assets asynchronously. + (do + (clear-font-storage-failure! font-data) + (tm/schedule + #(report-font-storage-failed! font-data)) + nil))) (defn serialize-font-style [font-style] @@ -245,18 +316,6 @@ "italic" 1 0)) -(defn normalize-font-id - [font-id] - (try - (if ^boolean (str/starts-with? font-id "gfont-") - (google-font-id->uuid font-id) - (let [no-prefix (subs font-id (inc (str/index-of font-id "-")))] - (if (or (nil? no-prefix) (not (string? no-prefix)) (str/blank? no-prefix)) - uuid/zero - (uuid/parse no-prefix)))) - (catch :default _e - uuid/zero))) - (defn normalize-span-font [span paragraph] (let [font-id (:font-id span) @@ -358,7 +417,7 @@ emoji? (get font :is-emoji false) fallback? (get font :is-fallback false) font-data (font-db-data font-id normalized-variant-id font-weight-fallback font-style-fallback) - wasm-id (font-id->uuid font-id) + wasm-id (cfnt/font-id->uuid font-id) raw-weight (or (:weight font-data) font-weight-fallback) weight (serialize-font-weight raw-weight) style (cond @@ -415,7 +474,3 @@ (defn store-fonts [fonts] (keep (fn [font] (store-font font)) fonts)) - -(def add-emoji-font fbf/add-emoji-font) -(def noto-fonts fbf/noto-fonts) -(def add-noto-fonts fbf/add-noto-fonts) diff --git a/frontend/src/app/render_wasm/api/shapes.cljs b/frontend/src/app/render_wasm/api/shapes.cljs deleted file mode 100644 index 02c2c91ee2..0000000000 --- a/frontend/src/app/render_wasm/api/shapes.cljs +++ /dev/null @@ -1,193 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL - -(ns app.render-wasm.api.shapes - "Batched shape property serialization for improved WASM performance. - - This module provides a single WASM call to set all base shape properties, - replacing multiple individual calls (use_shape, set_parent, set_shape_type, - etc.) with one batched operation." - (:require - [app.common.data :as d] - [app.common.data.macros :as dm] - [app.common.uuid :as uuid] - [app.render-wasm.helpers :as h] - [app.render-wasm.mem :as mem] - [app.render-wasm.serializers :as sr] - [app.render-wasm.wasm :as wasm])) - -;; Binary layout constants matching Rust implementation: -;; -;; | Offset | Size | Field | Type | -;; |--------|------|--------------|-----------------------------------| -;; | 0 | 16 | id | UUID (4 × u32 LE) | -;; | 16 | 16 | parent_id | UUID (4 × u32 LE) | -;; | 32 | 1 | shape_type | u8 | -;; | 33 | 1 | flags | u8 (bit0: clip, bit1: hidden) | -;; | 34 | 1 | blend_mode | u8 | -;; | 35 | 1 | constraint_h | u8 (0xFF = None) | -;; | 36 | 1 | constraint_v | u8 (0xFF = None) | -;; | 37 | 3 | padding | - | -;; | 40 | 4 | opacity | f32 LE | -;; | 44 | 4 | rotation | f32 LE | -;; | 48 | 24 | transform | 6 × f32 LE (a,b,c,d,e,f) | -;; | 72 | 16 | selrect | 4 × f32 LE (x1,y1,x2,y2) | -;; | 88 | 16 | corners | 4 × f32 LE (r1,r2,r3,r4) | -;; |--------|------|--------------|-----------------------------------| -;; | Total | 104 | | | - -(def ^:const BASE-PROPS-SIZE 104) -(def ^:const FLAG-CLIP-CONTENT 0x01) -(def ^:const FLAG-HIDDEN 0x02) -(def ^:const CONSTRAINT-NONE 0xFF) - -(defn- write-uuid-to-heap - "Write a UUID to the heap at the given byte offset using DataView." - [dview offset id] - (let [buffer (uuid/get-u32 id)] - (.setUint32 dview offset (aget buffer 0) true) - (.setUint32 dview (+ offset 4) (aget buffer 1) true) - (.setUint32 dview (+ offset 8) (aget buffer 2) true) - (.setUint32 dview (+ offset 12) (aget buffer 3) true))) - -(defn- serialize-transform - "Extract transform matrix values, defaulting to identity matrix." - [transform] - (if (some? transform) - [(dm/get-prop transform :a) - (dm/get-prop transform :b) - (dm/get-prop transform :c) - (dm/get-prop transform :d) - (dm/get-prop transform :e) - (dm/get-prop transform :f)] - [1.0 0.0 0.0 1.0 0.0 0.0])) ; identity matrix - -(defn- serialize-selrect - "Extract selrect values." - [selrect] - (if (some? selrect) - [(dm/get-prop selrect :x1) - (dm/get-prop selrect :y1) - (dm/get-prop selrect :x2) - (dm/get-prop selrect :y2)] - [0.0 0.0 0.0 0.0])) - -(defn set-shape-base-props - "Set all base shape properties in a single WASM call. - - This replaces the following individual calls: - - use-shape - - set-parent-id - - set-shape-type - - set-shape-clip-content - - set-shape-rotation - - set-shape-transform - - set-shape-blend-mode - - set-shape-opacity - - set-shape-hidden - - set-shape-selrect - - set-shape-corners - - set-shape-constraints (clear + h + v) - - Returns nil." - [shape] - (when (wasm/live?) - (let [id (dm/get-prop shape :id) - parent-id (get shape :parent-id) - shape-type (dm/get-prop shape :type) - - clip-content (if (= shape-type :frame) - (not (get shape :show-content)) - false) - hidden (get shape :hidden false) - - flags (cond-> 0 - clip-content (bit-or FLAG-CLIP-CONTENT) - hidden (bit-or FLAG-HIDDEN)) - - blend-mode (sr/translate-blend-mode (get shape :blend-mode)) - constraint-h (let [c (get shape :constraints-h)] - (if (some? c) - (sr/translate-constraint-h c) - CONSTRAINT-NONE)) - constraint-v (let [c (get shape :constraints-v)] - (if (some? c) - (sr/translate-constraint-v c) - CONSTRAINT-NONE)) - - opacity (d/nilv (get shape :opacity) 1.0) - rotation (d/nilv (get shape :rotation) 0.0) - - ;; Transform matrix - [ta tb tc td te tf] (serialize-transform (get shape :transform)) - - ;; Selrect - selrect (get shape :selrect) - [sx1 sy1 sx2 sy2] (serialize-selrect selrect) - - ;; Corners - r1 (d/nilv (get shape :r1) 0.0) - r2 (d/nilv (get shape :r2) 0.0) - r3 (d/nilv (get shape :r3) 0.0) - r4 (d/nilv (get shape :r4) 0.0) - - ;; Allocate buffer and get DataView - offset (mem/alloc BASE-PROPS-SIZE) - heap (mem/get-heap-u8) - dview (js/DataView. (.-buffer heap))] - - ;; Write id (offset 0, 16 bytes) - (write-uuid-to-heap dview offset id) - - ;; Write parent_id (offset 16, 16 bytes) - (write-uuid-to-heap dview (+ offset 16) (d/nilv parent-id uuid/zero)) - - ;; Write shape_type (offset 32, 1 byte) - (.setUint8 dview (+ offset 32) (sr/translate-shape-type shape-type)) - - ;; Write flags (offset 33, 1 byte) - (.setUint8 dview (+ offset 33) flags) - - ;; Write blend_mode (offset 34, 1 byte) - (.setUint8 dview (+ offset 34) blend-mode) - - ;; Write constraint_h (offset 35, 1 byte) - (.setUint8 dview (+ offset 35) constraint-h) - - ;; Write constraint_v (offset 36, 1 byte) - (.setUint8 dview (+ offset 36) constraint-v) - - ;; Padding at offset 37-39 (already zero from alloc) - - ;; Write opacity (offset 40, f32) - (.setFloat32 dview (+ offset 40) opacity true) - - ;; Write rotation (offset 44, f32) - (.setFloat32 dview (+ offset 44) rotation true) - - ;; Write transform matrix (offset 48, 6 × f32) - (.setFloat32 dview (+ offset 48) ta true) - (.setFloat32 dview (+ offset 52) tb true) - (.setFloat32 dview (+ offset 56) tc true) - (.setFloat32 dview (+ offset 60) td true) - (.setFloat32 dview (+ offset 64) te true) - (.setFloat32 dview (+ offset 68) tf true) - - ;; Write selrect (offset 72, 4 × f32) - (.setFloat32 dview (+ offset 72) sx1 true) - (.setFloat32 dview (+ offset 76) sy1 true) - (.setFloat32 dview (+ offset 80) sx2 true) - (.setFloat32 dview (+ offset 84) sy2 true) - - ;; Write corners (offset 88, 4 × f32) - (.setFloat32 dview (+ offset 88) r1 true) - (.setFloat32 dview (+ offset 92) r2 true) - (.setFloat32 dview (+ offset 96) r3 true) - (.setFloat32 dview (+ offset 100) r4 true) - - (h/call wasm/internal-module "_set_shape_base_props") - - nil))) diff --git a/frontend/src/app/render_wasm/api/texts.cljs b/frontend/src/app/render_wasm/api/texts.cljs index 3ea55dfdb3..c27d39d2be 100644 --- a/frontend/src/app/render_wasm/api/texts.cljs +++ b/frontend/src/app/render_wasm/api/texts.cljs @@ -2,25 +2,17 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.api.texts (:require - [app.render-wasm.api.fonts :as f] - [app.render-wasm.fallback-fonts :as fbf] - [app.render-wasm.text-content :as tc])) + [app.common.render-wasm.text-content :as tc] + [app.render-wasm.api.fonts :as f])) (defn write-shape-text "Workspace text serialization: the byte writing is shared via - `app.render-wasm.text-content`; font resolution is the workspace's (fonts DB)." + `app.common.render-wasm.text-content`; font resolution is the workspace's (fonts DB)." [spans paragraph text] (tc/write-shape-text! spans paragraph text - {:normalize-font-id f/normalize-font-id - :normalize-paragraph f/normalize-paragraph-font + {:normalize-paragraph f/normalize-paragraph-font :normalize-span f/normalize-span-font})) - -;; Emoji/script detection lives in the host-agnostic -;; `app.render-wasm.fallback-fonts`; kept re-exported here for existing -;; workspace callers. -(def contains-emoji? fbf/contains-emoji?) -(def collect-used-languages fbf/collect-used-languages) diff --git a/frontend/src/app/render_wasm/api/webgl.cljs b/frontend/src/app/render_wasm/api/webgl.cljs index fc7a3fe37f..862374ec01 100644 --- a/frontend/src/app/render_wasm/api/webgl.cljs +++ b/frontend/src/app/render_wasm/api/webgl.cljs @@ -2,13 +2,13 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.api.webgl "WebGL utilities for pixel capture and rendering" (:require [app.common.logging :as log] - [app.render-wasm.wasm :as wasm] + [app.common.render-wasm.wasm :as wasm] [promesa.core :as p])) (defn get-webgl-context diff --git a/frontend/src/app/render_wasm/deserializers.cljs b/frontend/src/app/render_wasm/deserializers.cljs index 813cd9b24a..882c6f22be 100644 --- a/frontend/src/app/render_wasm/deserializers.cljs +++ b/frontend/src/app/render_wasm/deserializers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.deserializers (:require [app.common.data :as d] diff --git a/frontend/src/app/render_wasm/gesture.cljs b/frontend/src/app/render_wasm/gesture.cljs index 2e774e511c..05557e3f42 100644 --- a/frontend/src/app/render_wasm/gesture.cljs +++ b/frontend/src/app/render_wasm/gesture.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.gesture "WASM-linked pointer gestures (interactive transforms, like D&D)") diff --git a/frontend/src/app/render_wasm/performance.cljc b/frontend/src/app/render_wasm/performance.cljc index 8e0ce5a3e7..6825663a9f 100644 --- a/frontend/src/app/render_wasm/performance.cljc +++ b/frontend/src/app/render_wasm/performance.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.performance #?(:cljs (:require-macros [app.render-wasm.performance])) diff --git a/frontend/src/app/render_wasm/rulers_state.cljs b/frontend/src/app/render_wasm/rulers_state.cljs index 231907cec7..4f94047521 100644 --- a/frontend/src/app/render_wasm/rulers_state.cljs +++ b/frontend/src/app/render_wasm/rulers_state.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.rulers-state "Ruler overlay state derived from the workspace (no WASM/api deps)." diff --git a/frontend/src/app/render_wasm/serialize_shape.cljs b/frontend/src/app/render_wasm/serialize_shape.cljs deleted file mode 100644 index ffc659757e..0000000000 --- a/frontend/src/app/render_wasm/serialize_shape.cljs +++ /dev/null @@ -1,54 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL - -(ns app.render-wasm.serialize-shape - "Single source of truth for the host-independent part of serializing a whole - shape into the WASM design state. - - Both batch serializers call this so they can't drift: - - the workspace `app.render-wasm.api/set-object` (browser), and - - the headless exporter `app.wasm.serialize/set-shape!` (Node). - - It applies only the properties that need no host-specific resources or driver: - base props, children, blur, background blur, shadows, svg attrs, group mask, - bool type, path/bool geometry and text grow type. The parts that DO differ by - host are handled by each caller AFTER this runs: - - fills / strokes (image bytes are fetched + uploaded differently), - - text content (fonts), - - svg-raw markup (browser renders it via React), - - layout (grid/flex — workspace only). - - The incremental workspace edit path (`set-wasm-attr!`) is unaffected; it keeps - dispatching per changed key through the same underlying `props` setters." - (:require - [app.render-wasm.api.props :as props] - [app.render-wasm.api.shapes :as shapes])) - -(defn serialize-shape! - "Applies every host-independent WASM property of `shape`. `set-shape-base-props` - runs first because it selects the current shape (`use_shape`) the rest mutate." - [shape] - (let [type (get shape :type)] - (shapes/set-shape-base-props shape) - (props/set-shape-children (get shape :shapes)) - (props/set-shape-blur (get shape :blur)) - (props/set-shape-background-blur (get shape :background-blur)) - (props/set-shape-shadows (get shape :shadow)) - - (when (some? (get shape :svg-attrs)) - (props/set-shape-svg-attrs (get shape :svg-attrs))) - - (when (= type :group) - (props/set-masked (boolean (get shape :masked-group)))) - - (when (= type :bool) - (props/set-shape-bool-type (get shape :bool-type))) - - (when (and (contains? #{:path :bool} type) (some? (get shape :content))) - (props/set-shape-path-content (get shape :content))) - - (when (= type :text) - (props/set-shape-grow-type (get shape :grow-type))))) diff --git a/frontend/src/app/render_wasm/shape.cljs b/frontend/src/app/render_wasm/shape.cljs index 19e43e8eea..0dd5870675 100644 --- a/frontend/src/app/render_wasm/shape.cljs +++ b/frontend/src/app/render_wasm/shape.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.shape (:require @@ -252,12 +252,12 @@ (let [text-content-pending (api/set-shape-text-content id v) pending-thumbnails (vec text-content-pending) pending-full (vec (api/set-shape-text-images id v)) - font-pending-ids (when (some :callback text-content-pending) [id])] + text-font-state (api/text-font-state-for-shape shape)] ;; FIXME: this is a hack to process the pending tasks ;; asynchronously we should probably modify set-wasm-attr! ;; to return a list of callbacks to be executed in a ;; second pass. - (api/process-pending [shape] pending-thumbnails pending-full font-pending-ids api/noop-fn) + (api/process-pending [shape] pending-thumbnails pending-full text-font-state api/noop-fn) nil)) :grow-type @@ -341,7 +341,8 @@ (when (d/not-empty? shape-changes) (->> (rx/from shape-changes) (rx/mapcat (fn [[shape-id props]] (process-shape! (get objects shape-id) props))) - (rx/subs! #(api/request-render "set-wasm-attrs"))))))) + (rx/reduce conj []) + (rx/subs! (fn [_] (api/request-render "set-wasm-attrs")))))))) ;; `conj` empty set initialization (def conj* (fnil conj (d/ordered-set))) diff --git a/frontend/src/app/render_wasm/svg_fills.cljs b/frontend/src/app/render_wasm/svg_fills.cljs index 725933ef83..829612bb2b 100644 --- a/frontend/src/app/render_wasm/svg_fills.cljs +++ b/frontend/src/app/render_wasm/svg_fills.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.svg-fills (:require diff --git a/frontend/src/app/render_wasm/svg_filters.cljs b/frontend/src/app/render_wasm/svg_filters.cljs index 9f58be9a43..13cc8c00ed 100644 --- a/frontend/src/app/render_wasm/svg_filters.cljs +++ b/frontend/src/app/render_wasm/svg_filters.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.svg-filters (:require diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 9686889dab..2f60004eac 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -2,21 +2,23 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.text-editor "Text editor WASM bindings" (:require + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.serializers.color :as sr-clr] + [app.common.render-wasm.wasm :as wasm] [app.common.types.fills.impl :as types.fills.impl] [app.common.types.text :as txt] [app.common.uuid :as uuid] [app.main.fonts :as main-fonts] + ;; Required for side effects: binds the generated enums. + [app.render-wasm.api.enums] [app.render-wasm.api.fonts :as fonts] - [app.render-wasm.helpers :as h] - [app.render-wasm.mem :as mem] - [app.render-wasm.serializers :as sr] - [app.render-wasm.serializers.color :as sr-clr] - [app.render-wasm.wasm :as wasm] [app.util.color :as uc] [app.util.dom :as dom])) @@ -222,6 +224,12 @@ (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_pointer_down" x y))) +(defn text-editor-pointer-down-extend + "Extends the selection up to the pointer instead of collapsing the caret." + [{:keys [x y]}] + (when (wasm/ready?) + (h/call wasm/internal-module "_text_editor_pointer_down_extend" x y))) + (defn text-editor-pointer-move [{:keys [x y]}] (when (wasm/ready?) @@ -237,11 +245,6 @@ (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_update_blink" timestamp-ms))) -(defn text-editor-render-overlay - [] - (when (wasm/ready?) - (h/call wasm/internal-module "_text_editor_render_overlay"))) - (defn text-editor-render-caret "Re-compose the frame from the Backbuffer (the last full render) and draw the caret/selection overlay on top, submitting one atomic frame. Pixel identical @@ -345,10 +348,15 @@ :text-direction (sr/untranslate-text-direction (text-editor-get-style-property text-direction-state text-direction-value)) :text-decoration (sr/untranslate-text-decoration (text-editor-get-style-property text-decoration-state text-decoration-value)) :text-transform (sr/untranslate-text-transform (text-editor-get-style-property text-transform-state text-transform-value)) - :line-height (text-editor-get-style-property line-height-state line-height-value) - :letter-spacing (text-editor-get-style-property letter-spacing-state letter-spacing-value) - :font-size (text-editor-get-style-property font-size-state font-size-value) - :font-weight (text-editor-get-style-property font-weight-state font-weight-value) + ;; WASM reports size/weight as numbers, but the rest of Penpot (and the backend schema) expects strings. + :line-height (let [height (text-editor-get-style-property line-height-state line-height-value)] + (if (= height :multiple) height (str height))) + :letter-spacing (let [spacing (text-editor-get-style-property letter-spacing-state letter-spacing-value)] + (if (= spacing :multiple) spacing (str spacing))) + :font-size (let [size (text-editor-get-style-property font-size-state font-size-value)] + (if (= size :multiple) size (str size))) + :font-weight (let [weight (text-editor-get-style-property font-weight-state font-weight-value)] + (if (= weight :multiple) weight (str weight))) :font-style font-style-value :font-family (text-editor-get-style-property font-family-id-state font-id) :font-id (text-editor-get-style-property font-family-id-state font-id) @@ -437,6 +445,11 @@ (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_select_word_boundary" x y))) +(defn text-editor-select-paragraph + [{:keys [x y]}] + (when (wasm/ready?) + (h/call wasm/internal-module "_text_editor_select_paragraph" x y))) + (defn text-editor-blur [] (when (wasm/ready?) @@ -534,41 +547,72 @@ [shape-id content] (swap! shape-text-contents assoc shape-id content)) -(defn- merge-exported-texts-into-content - "Merge exported span texts back into the existing content tree. +;; Typography chosen at a collapsed caret: not applied to existing text, but +;; picked up (as a new span) by the next inserted text. Keyed by shape-id. +(def ^:private pending-caret-styles (atom {})) - The WASM editor may split or merge paragraphs (Enter / Backspace at - paragraph boundary), so the exported structure can differ from the - original. When extra paragraphs or spans appear we clone styling from - the nearest existing sibling; when fewer appear we truncate. +(defn merge-pending-caret-styles! + "Stack `styles` onto the shape's pending caret style." + [shape-id styles] + (swap! pending-caret-styles update shape-id merge styles)) - exported-texts vector of vectors [[\"span1\" \"span2\"] [\"p2s1\"]] - content existing Penpot content map (root -> paragraph-set -> …)" - [content exported-texts] +(defn get-pending-caret-styles + [shape-id] + (get @pending-caret-styles shape-id)) + +(defn clear-pending-caret-styles! + "Drop every pending caret style; only the active shape can hold one." + [] + (reset! pending-caret-styles {})) + +(defn merge-exported-texts-into-content + "Merge exported spans back into the existing content tree. + + The WASM editor may split or merge paragraphs (Enter / Backspace at a + paragraph boundary, paste of several lines), so the exported structure can + differ from the original one, and a positional merge would leave the text of + one paragraph wearing the styling of another. Every exported span carries the + position it had in the tree we last exchanged with WASM (`:p`/`:s`), so the + styling is taken from there; a span WASM never saw falls back to its position + and then to the last existing span. + + exported vector of paragraphs, each a vector of `{:p 0 :s 0 :t \"text\"}` + content existing Penpot content map (root -> paragraph-set -> …)" + [content exported] (let [para-set (first (get content :children)) orig-paras (get para-set :children) - num-orig (count orig-paras) last-orig-para (when (seq orig-paras) (last orig-paras)) template-span (when last-orig-para (-> last-orig-para :children last)) + + styling-para + (fn [para-idx spans] + (or (get orig-paras (get (first spans) :p)) + (get orig-paras para-idx) + last-orig-para)) + + styling-span + (fn [orig-para span-idx {:keys [p s]}] + (or (get-in orig-paras [p :children s]) + (get-in orig-para [:children span-idx]) + (-> orig-para :children last) + template-span)) + new-paras - (mapv (fn [para-idx exported-span-texts] - (let [orig-para (if (< para-idx num-orig) - (nth orig-paras para-idx) - (dissoc last-orig-para :children)) - orig-spans (get orig-para :children) - num-orig-spans (count orig-spans) - last-orig-span (when (seq orig-spans) (last orig-spans))] + (mapv (fn [para-idx spans] + (let [orig-para (styling-para para-idx spans)] (assoc orig-para :children - (mapv (fn [span-idx new-text] - (let [orig-span (if (< span-idx num-orig-spans) - (nth orig-spans span-idx) - (or last-orig-span template-span))] - (assoc orig-span :text new-text))) - (range (count exported-span-texts)) - exported-span-texts)))) - (range (count exported-texts)) - exported-texts) + (if (seq spans) + (mapv (fn [span-idx span] + (-> (styling-span orig-para span-idx span) + (assoc :text (get span :t)))) + (range (count spans)) + spans) + ;; A paragraph with no spans is dropped on the way + ;; back to WASM (and fails the content schema). + [(assoc (or template-span {}) :text "")])))) + (range (count exported)) + exported) new-para-set (assoc para-set :children new-paras)] (assoc content :children [new-para-set]))) @@ -598,9 +642,9 @@ [] (when (and (wasm/ready?) (text-editor-has-focus?)) (let [shape-id (text-editor-get-active-shape-id) - new-texts (text-editor-export-content)] + new-texts (when shape-id (text-editor-export-content))] (when (and shape-id new-texts) - (let [texts-clj (js->clj new-texts) + (let [texts-clj (js->clj new-texts :keywordize-keys true) ;; A brand-new empty text shape (single click) has no cached ;; content yet, so fall back to a default template so the first ;; keystrokes are synced back to the shape instead of dropped. @@ -623,10 +667,9 @@ {:start-para focus-para :start-offset focus-offset :end-para anchor-para :end-offset anchor-offset})) -(defn- apply-attrs-to-paragraph - "Apply attrs to spans within [sel-start, sel-end) char range of a single paragraph. - Splits spans at boundaries as needed." - [para sel-start sel-end attrs] +(defn apply-attrs-to-paragraph + "Apply `styles` (attrs map, or a fn per span) within [sel-start, sel-end), splitting spans." + [para sel-start sel-end styles] (let [spans (:children para) result (loop [spans spans @@ -640,13 +683,19 @@ span-end (+ pos span-len) ol-start (max pos sel-start) ol-end (min span-end sel-end) - has-overlap? (< ol-start ol-end)] + ;; An empty span has no range to overlap, but an empty + ;; line inside the selection still has to be restyled. + has-overlap? (or (< ol-start ol-end) + (and (zero? span-len) + (<= sel-start pos sel-end)))] (if (not has-overlap?) (recur (rest spans) span-end (conj acc span)) (let [before (when (> ol-start pos) (assoc span :text (subs text 0 (- ol-start pos)))) - selected (merge span attrs - {:text (subs text (- ol-start pos) (- ol-end pos))}) + selected (-> (if (fn? styles) + (styles span) + (merge span styles)) + (assoc :text (subs text (- ol-start pos) (- ol-end pos)))) after (when (< ol-end span-end) (assoc span :text (subs text (- ol-end pos))))] (recur (rest spans) span-end @@ -658,15 +707,81 @@ [para] (apply + (map (fn [span] (count (:text span))) (:children para)))) +(defn- paragraph-selected-spans + "Return the spans of `para` that overlap the [sel-start, sel-end) char range." + [para sel-start sel-end] + (loop [spans (:children para) + pos 0 + acc []] + (if (empty? spans) + acc + (let [span (first spans) + span-end (+ pos (count (:text span))) + overlap? (< (max pos sel-start) (min span-end sel-end))] + (recur (rest spans) span-end (cond-> acc overlap? (conj span))))))) + +(defn selection-fills + "The selection's fills: shared vector if all spans match, `:multiple` if not, nil if empty." + [content {:keys [start-para start-offset end-para end-offset]}] + (let [paragraphs (:children (first (:children content))) + selected (mapcat (fn [idx para] + (cond + (or (< idx start-para) (> idx end-para)) nil + (= start-para end-para) (paragraph-selected-spans para start-offset end-offset) + (= idx start-para) (paragraph-selected-spans para start-offset (para-char-count para)) + (= idx end-para) (paragraph-selected-spans para 0 end-offset) + :else (paragraph-selected-spans para 0 (para-char-count para)))) + (range (count paragraphs)) + paragraphs) + fills-set (into #{} (map :fills) selected)] + (cond + (empty? selected) nil + (= 1 (count fills-set)) (first fills-set) + :else :multiple))) + +(defn- apply-styles-over-range + "Apply `styles` (attrs map or per-span fn) to the char range of `content`, splitting spans." + [content {:keys [start-para start-offset end-para end-offset]} styles] + (let [paragraph-set (first (:children content)) + paragraphs (:children paragraph-set) + new-paragraphs (mapv (fn [idx para] + (cond + ;; paragraph outside the range of paragraphs. + (or (< idx start-para) (> idx end-para)) + para + + ;; same paragraph. + (= start-para end-para) + (apply-attrs-to-paragraph para start-offset end-offset styles) + + ;; first paragraph + (= idx start-para) + (apply-attrs-to-paragraph para start-offset (para-char-count para) styles) + + ;; final paragraph + (= idx end-para) + (apply-attrs-to-paragraph para 0 end-offset styles) + + ;; any other paragraph + :else + (apply-attrs-to-paragraph para 0 (para-char-count para) styles))) + (range (count paragraphs)) + paragraphs)] + (assoc content :children [(assoc paragraph-set :children new-paragraphs)]))) + +(defn- clean-styles + "Drop nil-valued attrs (unlike the DOM path, our merge would keep them and fail + the backend schema); a per-span fn is passed through untouched." + [styles] + (if (fn? styles) + styles + (into {} (remove (comp nil? val)) styles))) + (defn apply-styles-to-selection - [attrs use-shape-fn set-shape-text-content-fn] + "Apply `styles` (attrs map, or a fn per span) to the selected spans; `:with-fills?` also returns `:fills`." + [styles use-shape-fn set-shape-text-content-fn & [{:keys [with-fills?]}]] (when (wasm/ready?) - (let [;; Drop nil-valued attrs so they are never merged onto text spans. - ;; The DOM editor path strips these in `attrs->styles`; the WASM merge - ;; here (`apply-attrs-to-paragraph`) does not, so an unresolved attr - ;; (e.g. nil :font-family/:font-weight/:font-style from an unloaded - ;; font) would corrupt the span and fail the backend schema. - attrs (into {} (remove (comp nil? val)) attrs) + (let [styles (clean-styles styles) shape-id (text-editor-get-active-shape-id) selection (text-editor-get-selection)] @@ -676,45 +791,60 @@ (let [normalized-selection (normalize-selection selection) {:keys [start-para start-offset end-para end-offset]} normalized-selection - collapsed? (and (= start-para end-para) (= start-offset end-offset)) + collapsed? (and (= start-para end-para) (= start-offset end-offset)) - paragraph-set (first (:children content)) - paragraphs (:children paragraph-set) - - new-paragraphs - (when (not collapsed?) - (mapv (fn [idx para] - (cond - ;; paragraph outside the range of paragraphs. - (or (< idx start-para) (> idx end-para)) - para - - ;; same paragraph. - (= start-para end-para) - (apply-attrs-to-paragraph para start-offset end-offset attrs) - - ;; first paragraph - (= idx start-para) - (apply-attrs-to-paragraph para start-offset (para-char-count para) attrs) - - ;; final paragraph - (= idx end-para) - (apply-attrs-to-paragraph para 0 end-offset attrs) - - ;; any other paragraph - :else - (apply-attrs-to-paragraph para 0 (para-char-count para) attrs))) - - (range (count paragraphs)) - paragraphs)) - - new-content (when new-paragraphs - (assoc content :children - [(assoc paragraph-set :children new-paragraphs)]))] + new-content (when (not collapsed?) + (apply-styles-over-range content normalized-selection styles))] (when new-content (update-cached-content! shape-id new-content) (use-shape-fn shape-id) (set-shape-text-content-fn shape-id new-content) - {:shape-id shape-id - :content new-content})))))))) + (cond-> {:shape-id shape-id + :content new-content} + with-fills? + (assoc :fills (selection-fills new-content normalized-selection))))))))))) + +(defn apply-styles-to-range + "Like `apply-styles-to-selection` but over an explicit range (used to restyle + just-inserted text); returns `{:shape-id :content}` or nil." + [shape-id {:keys [start-para start-offset end-para end-offset] :as range} styles + use-shape-fn set-shape-text-content-fn] + (when (wasm/ready?) + (let [styles (clean-styles styles) + content (get-cached-content shape-id)] + (when (and content + (seq styles) + (not (and (= start-para end-para) (= start-offset end-offset)))) + (let [new-content (apply-styles-over-range content range styles)] + (update-cached-content! shape-id new-content) + (use-shape-fn shape-id) + (set-shape-text-content-fn shape-id new-content) + {:shape-id shape-id + :content new-content}))))) + +(defn apply-paragraph-attrs-to-selection + "Apply paragraph level attrs (text-align, text-direction) to the whole + paragraphs the editor selection touches; a collapsed caret means just the one + it sits in." + [attrs use-shape-fn set-shape-text-content-fn] + (when (wasm/ready?) + (let [shape-id (text-editor-get-active-shape-id) + selection (text-editor-get-selection)] + (when (and shape-id selection) + (when-let [content (get-cached-content shape-id)] + (let [{:keys [start-para end-para]} (normalize-selection selection) + paragraph-set (first (:children content)) + new-paragraphs (into [] + (map-indexed (fn [idx para] + (if (<= start-para idx end-para) + (merge para attrs) + para))) + (:children paragraph-set)) + new-content (assoc content :children + [(assoc paragraph-set :children new-paragraphs)])] + (update-cached-content! shape-id new-content) + (use-shape-fn shape-id) + (set-shape-text-content-fn shape-id new-content) + {:shape-id shape-id + :content new-content})))))) diff --git a/frontend/src/app/util/array.cljs b/frontend/src/app/util/array.cljs index e598d856d8..15d9dc5f63 100644 --- a/frontend/src/app/util/array.cljs +++ b/frontend/src/app/util/array.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.array "A collection of helpers for work with javascript arrays." diff --git a/frontend/src/app/util/avatars.cljs b/frontend/src/app/util/avatars.cljs index 92a3dc172b..abfbeb4998 100644 --- a/frontend/src/app/util/avatars.cljs +++ b/frontend/src/app/util/avatars.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.avatars (:require diff --git a/frontend/src/app/util/browser_history.js b/frontend/src/app/util/browser_history.js index 3bfdcb49ec..9421aaad2a 100644 --- a/frontend/src/app/util/browser_history.js +++ b/frontend/src/app/util/browser_history.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ "use strict"; diff --git a/frontend/src/app/util/cache.cljs b/frontend/src/app/util/cache.cljs index 6bf0e3f6cd..51939c773f 100644 --- a/frontend/src/app/util/cache.cljs +++ b/frontend/src/app/util/cache.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.cache (:require diff --git a/frontend/src/app/util/clipboard.cljs b/frontend/src/app/util/clipboard.cljs index 3bc1f1bba3..a23473b964 100644 --- a/frontend/src/app/util/clipboard.cljs +++ b/frontend/src/app/util/clipboard.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.clipboard (:require @@ -22,6 +22,18 @@ #js {:decodeTransit t/decode-str :allowHTMLPaste false}) +(defn plain-text->html + "Build a minimal text/html clipboard payload from plain text. + + Windows apps often prefer CF_HTML over CF_UNICODETEXT; writing only + text/plain from a contenteditable copy handler can leave them with the + editor surface's empty/`<br>` fallback (a lone newline)." + [text] + (let [escaped (-> (or text "") + (dom/escape-html) + (str/replace "\n" "<br>"))] + (str "<meta charset=\"utf-8\">" escaped))) + (defn- from-data-transfer "Get clipboard stream from DataTransfer instance" ([data-transfer] diff --git a/frontend/src/app/util/clipboard.js b/frontend/src/app/util/clipboard.js index 16476c83a3..2711521bad 100644 --- a/frontend/src/app/util/clipboard.js +++ b/frontend/src/app/util/clipboard.js @@ -4,7 +4,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ const maxParseableSize = 16 * 1024 * 1024; diff --git a/frontend/src/app/util/code_beautify.cljs b/frontend/src/app/util/code_beautify.cljs index f00459e479..1ffcee271c 100644 --- a/frontend/src/app/util/code_beautify.cljs +++ b/frontend/src/app/util/code_beautify.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.code-beautify (:require diff --git a/frontend/src/app/util/code_gen.cljs b/frontend/src/app/util/code_gen.cljs index 97ac9cb91d..ab5b6d540b 100644 --- a/frontend/src/app/util/code_gen.cljs +++ b/frontend/src/app/util/code_gen.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.code-gen (:require diff --git a/frontend/src/app/util/code_gen/common.cljs b/frontend/src/app/util/code_gen/common.cljs index 1455768a66..9e239b738c 100644 --- a/frontend/src/app/util/code_gen/common.cljs +++ b/frontend/src/app/util/code_gen/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.code-gen.common (:require diff --git a/frontend/src/app/util/code_gen/markup_html.cljs b/frontend/src/app/util/code_gen/markup_html.cljs index 896c024a00..a2a3ff863d 100644 --- a/frontend/src/app/util/code_gen/markup_html.cljs +++ b/frontend/src/app/util/code_gen/markup_html.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.code-gen.markup-html (:require diff --git a/frontend/src/app/util/code_gen/markup_svg.cljs b/frontend/src/app/util/code_gen/markup_svg.cljs index 774feae34e..2fa4cfa1b6 100644 --- a/frontend/src/app/util/code_gen/markup_svg.cljs +++ b/frontend/src/app/util/code_gen/markup_svg.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.code-gen.markup-svg (:require diff --git a/frontend/src/app/util/code_gen/style_css.cljs b/frontend/src/app/util/code_gen/style_css.cljs index 85cf87ee0b..2534404394 100644 --- a/frontend/src/app/util/code_gen/style_css.cljs +++ b/frontend/src/app/util/code_gen/style_css.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.code-gen.style-css (:require diff --git a/frontend/src/app/util/code_gen/style_css_formats.cljs b/frontend/src/app/util/code_gen/style_css_formats.cljs index 5f3741a638..60c47f16a6 100644 --- a/frontend/src/app/util/code_gen/style_css_formats.cljs +++ b/frontend/src/app/util/code_gen/style_css_formats.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.code-gen.style-css-formats (:require diff --git a/frontend/src/app/util/code_gen/style_css_values.cljs b/frontend/src/app/util/code_gen/style_css_values.cljs index 7bfda75b53..0b1dc28c7f 100644 --- a/frontend/src/app/util/code_gen/style_css_values.cljs +++ b/frontend/src/app/util/code_gen/style_css_values.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.code-gen.style-css-values diff --git a/frontend/src/app/util/code_highlight.cljs b/frontend/src/app/util/code_highlight.cljs index 70cc198579..5c7c2b3af3 100644 --- a/frontend/src/app/util/code_highlight.cljs +++ b/frontend/src/app/util/code_highlight.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.code-highlight (:require diff --git a/frontend/src/app/util/color.cljs b/frontend/src/app/util/color.cljs index d28c3ccfb6..85ee3ea6ea 100644 --- a/frontend/src/app/util/color.cljs +++ b/frontend/src/app/util/color.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.color "FIXME: this is legacy namespace, all functions of this ns should be diff --git a/frontend/src/app/util/debug.cljs b/frontend/src/app/util/debug.cljs index 8eb7dfaffe..3340e040e2 100644 --- a/frontend/src/app/util/debug.cljs +++ b/frontend/src/app/util/debug.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.debug (:require diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 6bdc663f42..ffb2bb06c1 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.dom (:require @@ -244,6 +244,16 @@ height (.-clientHeight scroll-node)] (/ distance height))) +(defn scroll-to-row + [node index] + (when (and (some? node) (number? index)) + (.scrollToRow ^js node index))) + +(defn scroll-to-position + [node offset] + (when (and (some? node) (number? offset)) + (.scrollToPosition ^js node offset))) + (def get-target-val (comp get-value get-target)) (def get-target-scroll (comp get-scroll-position get-target)) @@ -875,35 +885,17 @@ [url] (.replaceState (.-history globals/window) nil "" url)) -(defn- update-query-params - "Apply `f` to the query-params map of `url`, returning the updated URL string. - Handles both plain query strings and fragment-based (hash) URLs." - [url f] - (let [transform (fn [parsed] - (update parsed :query - (fn [q] - (-> (u/query-string->map (or q "")) - f - u/map->query-string)))) - parsed (u/uri url) - fragment (:fragment parsed)] - (if (str/blank? fragment) - (str (transform parsed)) - (-> parsed - (assoc :fragment (str (transform (u/parse fragment)))) - str)))) - (defn append-query-param "Return a new URL string with the given query parameter added or replaced. Handles both plain query strings and fragment-based (hash) URLs." [url key value] - (update-query-params url #(assoc % key value))) + (u/append-query-param url key value)) (defn remove-query-param "Return a new URL string with the given query parameter removed. Handles both plain query strings and fragment-based (hash) URLs." [url key] - (update-query-params url #(dissoc % key))) + (u/remove-query-param url key)) (defn reload-current-window ([] @@ -957,6 +949,36 @@ {:ascent (.-fontBoundingBoxAscent measure) :descent (.-fontBoundingBoxDescent measure)})) +(defn measure-text-metrics + "Measure the font-wide (bounding-box) and glyph-ink vertical metrics of `text` + at `font-size` px for the given font. + + Returns `{:font-ascent :font-descent :ink-ascent :ink-descent}` in px, or nil + when the browser doesn't expose the bounding-box metrics. The font-wide + values track what CSS uses for the line box, while the ink ones track the + visible glyphs, which is what an optical centering shift needs." + ([family weight style] + (measure-text-metrics family weight style "Ag" 16)) + ([family weight style text font-size] + (let [element (.createElement globals/document "canvas") + context (.getContext element "2d") + _ (set! (.-font context) + (dm/str (or weight "400") " " (or style "normal") " " + font-size "px \"" family "\"")) + measure ^js (.measureText context (str text)) + font-ascent (.-fontBoundingBoxAscent measure) + font-descent (.-fontBoundingBoxDescent measure) + ink-ascent (.-actualBoundingBoxAscent measure) + ink-descent (.-actualBoundingBoxDescent measure)] + (when (and (number? font-ascent) + (number? font-descent) + (number? ink-ascent) + (number? ink-descent)) + {:font-ascent font-ascent + :font-descent font-descent + :ink-ascent ink-ascent + :ink-descent ink-descent})))) + (defn clone-node ([^js node] (clone-node node true)) diff --git a/frontend/src/app/util/dom/dnd.cljs b/frontend/src/app/util/dom/dnd.cljs index bb647db785..ecff9a4c1d 100644 --- a/frontend/src/app/util/dom/dnd.cljs +++ b/frontend/src/app/util/dom/dnd.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.dom.dnd "Drag & Drop interop helpers." diff --git a/frontend/src/app/util/extends.cljs b/frontend/src/app/util/extends.cljs index afbd8172c9..295838b7d7 100644 --- a/frontend/src/app/util/extends.cljs +++ b/frontend/src/app/util/extends.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.extends "A dummy namespace for closure library and other global objects diff --git a/frontend/src/app/util/forms.cljs b/frontend/src/app/util/forms.cljs index 48bdb7b6f0..74eab6025a 100644 --- a/frontend/src/app/util/forms.cljs +++ b/frontend/src/app/util/forms.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.forms (:refer-clojure :exclude [uuid]) diff --git a/frontend/src/app/util/functions.cljs b/frontend/src/app/util/functions.cljs index b45af4b980..fc8e152b75 100644 --- a/frontend/src/app/util/functions.cljs +++ b/frontend/src/app/util/functions.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.functions "A functions helpers" diff --git a/frontend/src/app/util/globals.js b/frontend/src/app/util/globals.js index 2539c7a852..260a930ea4 100644 --- a/frontend/src/app/util/globals.js +++ b/frontend/src/app/util/globals.js @@ -4,7 +4,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /* diff --git a/frontend/src/app/util/http.cljs b/frontend/src/app/util/http.cljs index 32bb207c3a..62a8b1cac7 100644 --- a/frontend/src/app/util/http.cljs +++ b/frontend/src/app/util/http.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.http "A http client with rx streams interface." diff --git a/frontend/src/app/util/i18n.cljs b/frontend/src/app/util/i18n.cljs index 4992d61647..cac9757738 100644 --- a/frontend/src/app/util/i18n.cljs +++ b/frontend/src/app/util/i18n.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.i18n "A i18n foundation." diff --git a/frontend/src/app/util/json.cljs b/frontend/src/app/util/json.cljs index 4d424b9c99..fca01bc7db 100644 --- a/frontend/src/app/util/json.cljs +++ b/frontend/src/app/util/json.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.json) diff --git a/frontend/src/app/util/kdtree.cljs b/frontend/src/app/util/kdtree.cljs index c6a2ba58c8..a70827a690 100644 --- a/frontend/src/app/util/kdtree.cljs +++ b/frontend/src/app/util/kdtree.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.kdtree "A cljs layer on top of js impl of kdtree located in `kdtree_impl.js`." diff --git a/frontend/src/app/util/keyboard.cljs b/frontend/src/app/util/keyboard.cljs index c537758477..da4649ee6d 100644 --- a/frontend/src/app/util/keyboard.cljs +++ b/frontend/src/app/util/keyboard.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.keyboard (:require diff --git a/frontend/src/app/util/modules.clj b/frontend/src/app/util/modules.clj index 685d519f82..43823112ad 100644 --- a/frontend/src/app/util/modules.clj +++ b/frontend/src/app/util/modules.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.modules (:refer-clojure :exclude [load resolve])) diff --git a/frontend/src/app/util/modules.cljs b/frontend/src/app/util/modules.cljs index 73c608bec2..ab5c040cc5 100644 --- a/frontend/src/app/util/modules.cljs +++ b/frontend/src/app/util/modules.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.modules (:refer-clojure :exclude [import]) diff --git a/frontend/src/app/util/mouse.cljs b/frontend/src/app/util/mouse.cljs index b5cbfe229b..440fea3a4a 100644 --- a/frontend/src/app/util/mouse.cljs +++ b/frontend/src/app/util/mouse.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.mouse (:require diff --git a/frontend/src/app/util/navigator.cljs b/frontend/src/app/util/navigator.cljs index d7c125946d..4dd0ec3bb8 100644 --- a/frontend/src/app/util/navigator.cljs +++ b/frontend/src/app/util/navigator.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.navigator (:require [app.util.globals :as globals])) diff --git a/frontend/src/app/util/object.cljc b/frontend/src/app/util/object.cljc index f2dca618c6..6d1b078df1 100644 --- a/frontend/src/app/util/object.cljc +++ b/frontend/src/app/util/object.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL #_:clj-kondo/ignore (ns app.util.object diff --git a/frontend/src/app/util/path/arc_to_curve.js b/frontend/src/app/util/path/arc_to_curve.js index d1ab5a8e06..28ab2e15c9 100644 --- a/frontend/src/app/util/path/arc_to_curve.js +++ b/frontend/src/app/util/path/arc_to_curve.js @@ -4,7 +4,7 @@ * Is a modified and google closure compatible version of the a2c * functions by https://github.com/fontello/svgpath * - * @author KALEIDOS INC Sucursal en España SL + * @author KALEIDOS SUBSIDIARY SL * @license MIT License <https://opensource.org/licenses/MIT> */ diff --git a/frontend/src/app/util/path/simplify_curve.cljs b/frontend/src/app/util/path/simplify_curve.cljs index d6e96c4950..e72abc7501 100644 --- a/frontend/src/app/util/path/simplify_curve.cljs +++ b/frontend/src/app/util/path/simplify_curve.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.path.simplify-curve (:require diff --git a/frontend/src/app/util/perf.clj b/frontend/src/app/util/perf.clj index 21e7045892..0b95c972e9 100644 --- a/frontend/src/app/util/perf.clj +++ b/frontend/src/app/util/perf.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.perf "Performance profiling for react components.") diff --git a/frontend/src/app/util/perf.cljs b/frontend/src/app/util/perf.cljs index e04301cd5c..3d934197e6 100644 --- a/frontend/src/app/util/perf.cljs +++ b/frontend/src/app/util/perf.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.perf "Performance profiling for react components." diff --git a/frontend/src/app/util/queue.cljs b/frontend/src/app/util/queue.cljs index 4f534e555b..a44bb7d170 100644 --- a/frontend/src/app/util/queue.cljs +++ b/frontend/src/app/util/queue.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.queue "Low-Level queuing mechanism, mainly used for process thumbnails" diff --git a/frontend/src/app/util/range_tree.js b/frontend/src/app/util/range_tree.js index 60636a1201..ff27839413 100644 --- a/frontend/src/app/util/range_tree.js +++ b/frontend/src/app/util/range_tree.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /* diff --git a/frontend/src/app/util/rxops.cljs b/frontend/src/app/util/rxops.cljs index 292f82bd41..92478a0a3b 100644 --- a/frontend/src/app/util/rxops.cljs +++ b/frontend/src/app/util/rxops.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.rxops (:require diff --git a/frontend/src/app/util/session_state.cljs b/frontend/src/app/util/session_state.cljs index d16f3b6779..ade0091866 100644 --- a/frontend/src/app/util/session_state.cljs +++ b/frontend/src/app/util/session_state.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.session-state "Helpers for persisting transient state in browser session storage diff --git a/frontend/src/app/util/shape_icon.cljs b/frontend/src/app/util/shape_icon.cljs index 107a2173c1..e8de4e50b0 100644 --- a/frontend/src/app/util/shape_icon.cljs +++ b/frontend/src/app/util/shape_icon.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.shape-icon (:require diff --git a/frontend/src/app/util/simple_math.cljs b/frontend/src/app/util/simple_math.cljs index 9831ec3d90..4857456900 100644 --- a/frontend/src/app/util/simple_math.cljs +++ b/frontend/src/app/util/simple_math.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.simple-math (:require diff --git a/frontend/src/app/util/sse.cljs b/frontend/src/app/util/sse.cljs index a286083f70..9bd797c5e8 100644 --- a/frontend/src/app/util/sse.cljs +++ b/frontend/src/app/util/sse.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.sse (:require diff --git a/frontend/src/app/util/storage.cljs b/frontend/src/app/util/storage.cljs index a6a971602c..6a3e3e282c 100644 --- a/frontend/src/app/util/storage.cljs +++ b/frontend/src/app/util/storage.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.storage (:require diff --git a/frontend/src/app/util/strings.cljs b/frontend/src/app/util/strings.cljs index 115b24c7e1..83b452b510 100644 --- a/frontend/src/app/util/strings.cljs +++ b/frontend/src/app/util/strings.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.strings (:require diff --git a/frontend/src/app/util/text/content.cljs b/frontend/src/app/util/text/content.cljs index 4c14786a8e..5e6212d3cf 100644 --- a/frontend/src/app/util/text/content.cljs +++ b/frontend/src/app/util/text/content.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.text.content (:require diff --git a/frontend/src/app/util/text/content/from_dom.cljs b/frontend/src/app/util/text/content/from_dom.cljs index 19d0293287..89e25bce2b 100644 --- a/frontend/src/app/util/text/content/from_dom.cljs +++ b/frontend/src/app/util/text/content/from_dom.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.text.content.from-dom (:require diff --git a/frontend/src/app/util/text/content/styles.cljs b/frontend/src/app/util/text/content/styles.cljs index 20a2454e1f..e4c50ae22a 100644 --- a/frontend/src/app/util/text/content/styles.cljs +++ b/frontend/src/app/util/text/content/styles.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.text.content.styles (:require diff --git a/frontend/src/app/util/text/content/to_dom.cljs b/frontend/src/app/util/text/content/to_dom.cljs index cd7ab9d5aa..0f7886ef4e 100644 --- a/frontend/src/app/util/text/content/to_dom.cljs +++ b/frontend/src/app/util/text/content/to_dom.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.text.content.to-dom (:require diff --git a/frontend/src/app/util/text/ui.cljs b/frontend/src/app/util/text/ui.cljs index a6242cb7e3..226731c552 100644 --- a/frontend/src/app/util/text/ui.cljs +++ b/frontend/src/app/util/text/ui.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.text.ui (:require @@ -46,8 +46,18 @@ [] (dom/query "[data-itype=\"editor\"]")) +(defn v3-get-text-editor-content + [] + (dom/get-element "text-editor-wasm-input")) + (defn get-text-editor-content [] - (if (features/active-feature? @st/state "text-editor/v2") + (cond + (features/active-feature? @st/state "text-editor-wasm/v1") + (v3-get-text-editor-content) + + (features/active-feature? @st/state "text-editor/v2") (v2-get-text-editor-content) + + :else (v1-get-text-editor-content))) diff --git a/frontend/src/app/util/text_editor.cljs b/frontend/src/app/util/text_editor.cljs index 4e91ee80ef..fb7b819736 100644 --- a/frontend/src/app/util/text_editor.cljs +++ b/frontend/src/app/util/text_editor.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.text-editor "Draft related abstraction functions." diff --git a/frontend/src/app/util/text_position_data.js b/frontend/src/app/util/text_position_data.js index ca0a7e049e..e5b48d0f8a 100644 --- a/frontend/src/app/util/text_position_data.js +++ b/frontend/src/app/util/text_position_data.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ "use strict"; diff --git a/frontend/src/app/util/text_svg_position.cljs b/frontend/src/app/util/text_svg_position.cljs index 772a7f6b8a..13558db6de 100644 --- a/frontend/src/app/util/text_svg_position.cljs +++ b/frontend/src/app/util/text_svg_position.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.text-svg-position (:require diff --git a/frontend/src/app/util/theme.cljs b/frontend/src/app/util/theme.cljs index 07b21ec6cb..75c0837d40 100644 --- a/frontend/src/app/util/theme.cljs +++ b/frontend/src/app/util/theme.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.theme (:require diff --git a/frontend/src/app/util/thumbnails.cljs b/frontend/src/app/util/thumbnails.cljs index db45329441..c0865d3cd7 100644 --- a/frontend/src/app/util/thumbnails.cljs +++ b/frontend/src/app/util/thumbnails.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.thumbnails (:require diff --git a/frontend/src/app/util/timers.cljs b/frontend/src/app/util/timers.cljs index 752379a39d..be5a13e61c 100644 --- a/frontend/src/app/util/timers.cljs +++ b/frontend/src/app/util/timers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.timers (:require diff --git a/frontend/src/app/util/webapi.cljs b/frontend/src/app/util/webapi.cljs index 250b01bced..4c8aaa484b 100644 --- a/frontend/src/app/util/webapi.cljs +++ b/frontend/src/app/util/webapi.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.webapi "HTML5 web api helpers." diff --git a/frontend/src/app/util/websocket.cljs b/frontend/src/app/util/websocket.cljs index e192533b76..bac6240860 100644 --- a/frontend/src/app/util/websocket.cljs +++ b/frontend/src/app/util/websocket.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.websocket "A interface to webworkers exposed functionality." diff --git a/frontend/src/app/util/worker.cljs b/frontend/src/app/util/worker.cljs index 58b960c070..f37bb30914 100644 --- a/frontend/src/app/util/worker.cljs +++ b/frontend/src/app/util/worker.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.worker "A lightweight layer on top of webworkers api." diff --git a/frontend/src/app/util/zip.cljs b/frontend/src/app/util/zip.cljs index 2288cbdaae..42bec58c4d 100644 --- a/frontend/src/app/util/zip.cljs +++ b/frontend/src/app/util/zip.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.zip "Helpers for make zip file." diff --git a/frontend/src/app/worker.cljs b/frontend/src/app/worker.cljs index 52dd4a2374..da6f0c8a95 100644 --- a/frontend/src/app/worker.cljs +++ b/frontend/src/app/worker.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker (:require diff --git a/frontend/src/app/worker/impl.cljs b/frontend/src/app/worker/impl.cljs index e9d23f3122..5461756ff5 100644 --- a/frontend/src/app/worker/impl.cljs +++ b/frontend/src/app/worker/impl.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.impl "Dispatcher for messages received from the main thread." diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs index ba6a49ce1e..e8310efa89 100644 --- a/frontend/src/app/worker/import.cljs +++ b/frontend/src/app/worker/import.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.import (:refer-clojure :exclude [resolve]) @@ -171,8 +171,9 @@ (defmethod impl/handler :import-files [{:keys [project-id files]}] - (let [binfile-v1 (filter #(= :binfile-v1 (:type %)) files) - binfile-v3 (filter #(= :binfile-v3 (:type %)) files)] + (let [binfile-v1 (filter #(= :binfile-v1 (:type %)) files) + binfile-v3 (filter #(= :binfile-v3 (:type %)) files) + resolutions (volatile! {})] (rx/merge (->> (rx/from binfile-v1) @@ -203,40 +204,50 @@ :error (import-cause-message cause (tr "labels.error")) :file-id (:file-id data)}))))))) - (->> (rx/from binfile-v3) - (rx/reduce (fn [result file] - (update result (:uri file) (fnil conj []) file)) - {}) - (rx/mapcat identity) - (rx/merge-map - (fn [[uri entries]] - (->> (import-blob-via-upload uri - {:name (-> entries first :name) - :version 3 - :project-id project-id}) - (rx/tap (fn [event] - (let [payload (sse/get-payload event) - type (sse/get-type event)] - (if (= type "progress") - (log/dbg :hint "import-binfile: progress" - :section (:section payload) - :name (:name payload)) - (log/dbg :hint "import-binfile: end"))))) - (rx/filter sse/end-of-stream?) - (rx/mapcat (fn [_] - (->> (rx/from entries) - (rx/map (fn [entry] - {:status :finish - :file-id (:file-id entry)}))))) - (rx/catch - (fn [cause] - (log/error :hint "unexpected error on import process" - :project-id project-id - ::log/sync? true - :cause cause) - (let [err (import-cause-message cause (tr "labels.error"))] - (->> (rx/from entries) - (rx/map (fn [entry] - {:status :error - :error err - :file-id (:file-id entry)}))))))))))))) + + (rx/concat + (->> (rx/from binfile-v3) + (rx/reduce (fn [result file] + (update result (:uri file) (fnil conj []) file)) + {}) + (rx/mapcat identity) + (rx/merge-map + (fn [[uri entries]] + (->> (import-blob-via-upload uri + {:name (-> entries first :name) + :version 3 + :project-id project-id}) + (rx/tap (fn [event] + (let [payload (sse/get-payload event) + type (sse/get-type event)] + (cond + (= type "progress") + (log/dbg :hint "import-binfile: progress" + :section (:section payload) + :name (:name payload)) + + :else + (log/dbg :hint "import-binfile: end"))))) + (rx/filter sse/end-of-stream?) + (rx/mapcat (fn [message] + (let [{:keys [resolution]} (sse/get-payload message)] + (when (seq resolution) + (vswap! resolutions merge resolution)) + (->> (rx/from entries) + (rx/map (fn [entry] + {:status :finish + :file-id (:file-id entry)})))))) + (rx/catch (fn [cause] + (log/error :hint "import-binfile: unexpected error on importing" + :project-id project-id + ::log/sync? true + :cause cause) + (let [err (import-cause-message cause (tr "labels.error"))] + (->> (rx/from entries) + (rx/map (fn [entry] + {:status :error + :error err + :file-id (:file-id entry)})))))))))) + (->> (rx/defer #(rx/of @resolutions)) + (rx/map (fn [resolutions] + {:libraries-resolution resolutions}))))))) diff --git a/frontend/src/app/worker/index.cljs b/frontend/src/app/worker/index.cljs index 4d158782d5..1117440563 100644 --- a/frontend/src/app/worker/index.cljs +++ b/frontend/src/app/worker/index.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.index "Page index management within the worker." diff --git a/frontend/src/app/worker/messages.cljs b/frontend/src/app/worker/messages.cljs index 8b846f00cf..2bb1c6a859 100644 --- a/frontend/src/app/worker/messages.cljs +++ b/frontend/src/app/worker/messages.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.messages "A lightweight layer on top of webworkers api." diff --git a/frontend/src/app/worker/selection.cljs b/frontend/src/app/worker/selection.cljs index 0422d156ef..4fc1039be8 100644 --- a/frontend/src/app/worker/selection.cljs +++ b/frontend/src/app/worker/selection.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.selection (:require diff --git a/frontend/src/app/worker/snap.cljs b/frontend/src/app/worker/snap.cljs index f7f3963ed0..afc418b061 100644 --- a/frontend/src/app/worker/snap.cljs +++ b/frontend/src/app/worker/snap.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.snap "Data structure that holds and retrieves the data to make the snaps. diff --git a/frontend/src/app/worker/thumbnails.cljs b/frontend/src/app/worker/thumbnails.cljs index 21d4e37190..e42a9687b4 100644 --- a/frontend/src/app/worker/thumbnails.cljs +++ b/frontend/src/app/worker/thumbnails.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.thumbnails (:require @@ -11,6 +11,7 @@ [app.common.geom.rect :as grc] [app.common.geom.shapes.bounds :as gsb] [app.common.logging :as log] + [app.common.render-wasm.wasm :as wasm] [app.common.types.color :as cc] [app.common.uri :as u] [app.common.uuid :as uuid] @@ -18,7 +19,6 @@ [app.main.fonts :as fonts] [app.main.render :as render] [app.render-wasm.api :as wasm.api] - [app.render-wasm.wasm :as wasm] [app.util.http :as http] [app.worker.impl :as impl] [beicon.v2.core :as rx] diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index fb5cd5b5fe..057605a5a2 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns debug (:require @@ -15,6 +15,9 @@ [app.common.json :as json] [app.common.logging :as l] [app.common.pprint :as pp] + [app.common.render-wasm.helpers :as wasm.h] + [app.common.render-wasm.mem :as wasm.mem] + [app.common.render-wasm.wasm :as wasm] [app.common.transit :as t] [app.common.types.component :as ctk] [app.common.types.components-list :as ctkl] @@ -36,9 +39,6 @@ [app.main.errors :as errors] [app.main.repo :as rp] [app.main.store :as st] - [app.render-wasm.helpers :as wasm.h] - [app.render-wasm.mem :as wasm.mem] - [app.render-wasm.wasm :as wasm] [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.http :as http] diff --git a/frontend/src/features.cljs b/frontend/src/features.cljs index d9f387e144..33548e96db 100644 --- a/frontend/src/features.cljs +++ b/frontend/src/features.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL ;; This namespace is only to export the functions for toggle features (ns features diff --git a/frontend/test/frontend_tests/basic_shapes_test.cljs b/frontend/test/frontend_tests/basic_shapes_test.cljs index a114b06868..1082e2435e 100644 --- a/frontend/test/frontend_tests/basic_shapes_test.cljs +++ b/frontend/test/frontend_tests/basic_shapes_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.basic-shapes-test (:require diff --git a/frontend/test/frontend_tests/code_gen_style_test.cljs b/frontend/test/frontend_tests/code_gen_style_test.cljs index 4f04dab929..fb10dfef85 100644 --- a/frontend/test/frontend_tests/code_gen_style_test.cljs +++ b/frontend/test/frontend_tests/code_gen_style_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.code-gen-style-test "Regression tests for the inspect code-generation (HTML/CSS export). diff --git a/frontend/test/frontend_tests/composable_tests/comp/nodes.cljs b/frontend/test/frontend_tests/composable_tests/comp/nodes.cljs index 9d2089d601..7c13afc4fd 100644 --- a/frontend/test/frontend_tests/composable_tests/comp/nodes.cljs +++ b/frontend/test/frontend_tests/composable_tests/comp/nodes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.composable-tests.comp.nodes "Component-specific operation nodes for the test model. diff --git a/frontend/test/frontend_tests/composable_tests/comp/setups.cljs b/frontend/test/frontend_tests/composable_tests/comp/setups.cljs index 056da9c450..9c40acb214 100644 --- a/frontend/test/frontend_tests/composable_tests/comp/setups.cljs +++ b/frontend/test/frontend_tests/composable_tests/comp/setups.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.composable-tests.comp.setups "Component-specific setups for the test model: named functions that build an diff --git a/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs b/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs index 11821550b0..373fb9408e 100644 --- a/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs +++ b/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.composable-tests.comp.sync-test "Component-behaviour cases authored on the composable test model, run against diff --git a/frontend/test/frontend_tests/composable_tests/core.cljs b/frontend/test/frontend_tests/composable_tests/core.cljs index 6c1221658b..ba0054ee2f 100644 --- a/frontend/test/frontend_tests/composable_tests/core.cljs +++ b/frontend/test/frontend_tests/composable_tests/core.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.composable-tests.core "The domain-agnostic ENGINE of the composable test model (see diff --git a/frontend/test/frontend_tests/composable_tests/interpreter.cljs b/frontend/test/frontend_tests/composable_tests/interpreter.cljs index 0a6f3b9734..905604466c 100644 --- a/frontend/test/frontend_tests/composable_tests/interpreter.cljs +++ b/frontend/test/frontend_tests/composable_tests/interpreter.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.composable-tests.interpreter "FRONTEND interpreter + test-facing `check` for the composable test model. @@ -47,11 +47,13 @@ [app.main.data.workspace.transforms :as dwt] [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.variants :as dwv] + [app.main.repo :as rp] [app.main.store :as st] [beicon.v2.core :as rx] [cljs.test :as t] [frontend-tests.composable-tests.comp.nodes :as n] [frontend-tests.composable-tests.core :as tm] + [frontend-tests.helpers.mock :as mock] [potok.v2.core :as ptk])) ;; -------------------------------------------------------------------------- @@ -351,17 +353,11 @@ situation))) (defn- op-grace-ms - "Extra wait AFTER an event-op has settled, before proceeding. Zero for all ops - except `SyncFromLibrary`: the production `sync-file` event additionally - schedules `rx/timer 3000` + an `:update-file-library-sync-status` RPC. There is - no backend in the headless runner, so that delayed call fails (benignly) — but - 3s after the sync it would land INSIDE whatever test is then running, leaking - an error trace across test boundaries (and historically destabilising - whole-suite runs). Waiting it out here absorbs the failure within the test that - caused it." - [op] - (let [op (if (tm/recorded-choice? op) (tm/choice-of op) op)] - (if (instance? n/SyncFromLibrary op) 3200 0))) + "Extra wait AFTER an event-op has settled, before proceeding. Always zero: + the `rx/timer` and `rp/cmd!` calls that `SyncFromLibrary` schedules are + mocked (see `check`) so they fire instantly and succeed." + [_op] + 0) (defn- run-ops "Async fold over `ops` (concrete operation units, in order — plain ops and/or @@ -464,22 +460,31 @@ references the test holds (e.g. `has-property-of` on a change node). In-file propagation is AUTOMATIC (the watcher) — no propagate op is added. + Mocks are installed for the duration of the check: `rp/cmd!` returns + success (recording calls) and `rx/timer` fires instantly, so the + `SyncFromLibrary` op's delayed RPC does not produce network errors. + Arities: `(check done case-map)` or `(check done case-map asserter)`." ([done case-map] (check done case-map nil)) ([done {:keys [setup operation]} asserter] - (let [variants (tm/enumerate operation)] - (letfn [(run-next [vs] - (if (empty? vs) - (done) - (run-variant - setup - ;; a variant is a composed operation; flatten to its ordered leaf - ;; ops. `enumerate` already removed all one-of choices, so the - ;; variant is a Sequence (or a single op). - (tm/sequence-ops (first vs)) - (fn [situation] - (when asserter - (t/testing (str "operations:\n " (tm/describe-applied situation)) - (asserter situation))) - (run-next (rest vs))))))] - (run-next variants))))) + (mock/with-mocks + {rp/cmd! mock/rpc-cmd-mock + rx/timer mock/timer-mock} + (fn [inner-done] + (let [variants (tm/enumerate operation)] + (letfn [(run-next [vs] + (if (empty? vs) + (inner-done) + (run-variant + setup + ;; a variant is a composed operation; flatten to its ordered leaf + ;; ops. `enumerate` already removed all one-of choices, so the + ;; variant is a Sequence (or a single op). + (tm/sequence-ops (first vs)) + (fn [situation] + (when asserter + (t/testing (str "operations:\n " (tm/describe-applied situation)) + (asserter situation))) + (run-next (rest vs))))))] + (run-next variants)))) + done))) diff --git a/frontend/test/frontend_tests/copy_as_svg_test.cljs b/frontend/test/frontend_tests/copy_as_svg_test.cljs index c2aee4a298..c404a86fcd 100644 --- a/frontend/test/frontend_tests/copy_as_svg_test.cljs +++ b/frontend/test/frontend_tests/copy_as_svg_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.copy-as-svg-test "Regression tests for the Copy as SVG action (issue #838). diff --git a/frontend/test/frontend_tests/data/dashboard_test.cljs b/frontend/test/frontend_tests/data/dashboard_test.cljs index e73ebb889a..001ea2f924 100644 --- a/frontend/test/frontend_tests/data/dashboard_test.cljs +++ b/frontend/test/frontend_tests/data/dashboard_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.dashboard-test (:require diff --git a/frontend/test/frontend_tests/data/exports_assets_test.cljs b/frontend/test/frontend_tests/data/exports_assets_test.cljs index d4ae9edea8..d6b4f36a86 100644 --- a/frontend/test/frontend_tests/data/exports_assets_test.cljs +++ b/frontend/test/frontend_tests/data/exports_assets_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.exports-assets-test (:require diff --git a/frontend/test/frontend_tests/data/nitrate_test.cljs b/frontend/test/frontend_tests/data/nitrate_test.cljs index 2c59912c70..f21a438542 100644 --- a/frontend/test/frontend_tests/data/nitrate_test.cljs +++ b/frontend/test/frontend_tests/data/nitrate_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.nitrate-test (:require diff --git a/frontend/test/frontend_tests/data/profile_test.cljs b/frontend/test/frontend_tests/data/profile_test.cljs new file mode 100644 index 0000000000..f24c2d87f2 --- /dev/null +++ b/frontend/test/frontend_tests/data/profile_test.cljs @@ -0,0 +1,30 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.data.profile-test + (:require + [app.common.uuid :as uuid] + [app.main.data.profile :as dprof] + [cljs.test :as t :include-macros true])) + +(t/deftest profile-update-params-omits-nil-values + (t/is (= {:fullname "Updated Name"} + (dprof/profile-update-params {:fullname "Updated Name" + :lang nil + :theme nil})))) + +(t/deftest profile-update-params-preserves-present-values + (t/is (= {:fullname "Updated Name" + :lang "en" + :theme "dark"} + (dprof/profile-update-params {:fullname "Updated Name" + :lang "en" + :theme "dark"})))) + +(t/deftest update-profile-accepts-nil-optional-values + (t/is (some? (dprof/update-profile {:id uuid/zero + :fullname "Updated Name" + :theme nil})))) diff --git a/frontend/test/frontend_tests/data/repo_test.cljs b/frontend/test/frontend_tests/data/repo_test.cljs index 6b0f99e742..13066fbe9e 100644 --- a/frontend/test/frontend_tests/data/repo_test.cljs +++ b/frontend/test/frontend_tests/data/repo_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.repo-test (:require diff --git a/frontend/test/frontend_tests/data/store_test.cljs b/frontend/test/frontend_tests/data/store_test.cljs index 56d29fec85..89fd656109 100644 --- a/frontend/test/frontend_tests/data/store_test.cljs +++ b/frontend/test/frontend_tests/data/store_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.store-test "Unit tests for app.main.store. diff --git a/frontend/test/frontend_tests/data/uploads_test.cljs b/frontend/test/frontend_tests/data/uploads_test.cljs index fe1ea50ecc..0a7806b5cc 100644 --- a/frontend/test/frontend_tests/data/uploads_test.cljs +++ b/frontend/test/frontend_tests/data/uploads_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.uploads-test "Integration tests for the generic chunked-upload logic in diff --git a/frontend/test/frontend_tests/data/viewer_test.cljs b/frontend/test/frontend_tests/data/viewer_test.cljs index 5125ca9674..bc0d35a998 100644 --- a/frontend/test/frontend_tests/data/viewer_test.cljs +++ b/frontend/test/frontend_tests/data/viewer_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.viewer-test (:require diff --git a/frontend/test/frontend_tests/data/workspace_colors_test.cljs b/frontend/test/frontend_tests/data/workspace_colors_test.cljs index 7141ed10c0..2ae0150078 100644 --- a/frontend/test/frontend_tests/data/workspace_colors_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_colors_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-colors-test (:require diff --git a/frontend/test/frontend_tests/data/workspace_comments_test.cljs b/frontend/test/frontend_tests/data/workspace_comments_test.cljs index 8e653875c1..f5428d6c97 100644 --- a/frontend/test/frontend_tests/data/workspace_comments_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_comments_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-comments-test (:require diff --git a/frontend/test/frontend_tests/data/workspace_interactions_test.cljs b/frontend/test/frontend_tests/data/workspace_interactions_test.cljs index 2d21651991..d15602816d 100644 --- a/frontend/test/frontend_tests/data/workspace_interactions_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_interactions_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-interactions-test (:require diff --git a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs index 7f16894984..76b8fbd948 100644 --- a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-mcp-test (:require diff --git a/frontend/test/frontend_tests/data/workspace_media_test.cljs b/frontend/test/frontend_tests/data/workspace_media_test.cljs index 43fb802c88..cabdd54abb 100644 --- a/frontend/test/frontend_tests/data/workspace_media_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_media_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-media-test "Integration tests for the chunked-upload logic in diff --git a/frontend/test/frontend_tests/data/workspace_pages_test.cljs b/frontend/test/frontend_tests/data/workspace_pages_test.cljs index f5d2d9d2eb..8ec11e41b4 100644 --- a/frontend/test/frontend_tests/data/workspace_pages_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_pages_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-pages-test (:require diff --git a/frontend/test/frontend_tests/data/workspace_path_edition_test.cljs b/frontend/test/frontend_tests/data/workspace_path_edition_test.cljs new file mode 100644 index 0000000000..6ded7bc539 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_path_edition_test.cljs @@ -0,0 +1,107 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.data.workspace-path-edition-test + (:require + [app.common.data :as d] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.shapes :as cths] + [app.main.data.shortcuts :as dsc] + [app.main.data.workspace :as dw] + [app.main.data.workspace.path.edition :as path.edition] + [app.main.data.workspace.path.shortcuts :as psc] + [app.main.data.workspace.selection :as dws] + [app.main.data.workspace.shortcuts :as wsc] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.pages :as thp] + [frontend-tests.helpers.state :as ths] + [potok.v2.core :as ptk])) + +(t/use-fixtures :each + {:before thp/reset-idmap!}) + +(defn- enter-command? + [command] + (if (vector? command) + (some #(= % "enter") command) + (= command "enter"))) + +(t/deftest test-enter-key-is-bound-once-while-path-editing + ;; Regression test for the physical "enter" key ending up bound to + ;; two different shortcuts at once while path editing is active: one + ;; that (re)enters edition mode and one that exits it. `push-shortcuts` + ;; merges shortcut groups by map key (see `app.main.data.shortcuts`), + ;; not by physical key/command, so two shortcuts under different keys + ;; that both claim "enter" survive the merge and both would fire on a + ;; single keypress, breaking the toggle. + (let [file (cthf/sample-file :file1) + store (ths/setup-store file)] + (ptk/emit! store (dsc/push-shortcuts ::workspace wsc/shortcuts :workspace)) + (ptk/emit! store (dsc/push-shortcuts ::path psc/shortcuts :workspace :merge-shortcuts :auto)) + (let [effective (get-in @store [:shortcuts ::path]) + matches (->> effective + (filter (fn [[_ sc]] (enter-command? (:command sc)))) + (map first))] + (t/is (= 1 (count matches)) + (str "expected exactly one shortcut bound to \"enter\" while path editing, got " matches))))) + +(defn- run-scenario + [shape-type] + (let [file (-> (cthf/sample-file :file1) + (cths/add-sample-shape :test-shape :type shape-type)) + shape-id (:id (cths/get-shape file :test-shape)) + store (ths/setup-store file)] + ;; Select the shape, then reproduce what a physical Enter keypress + ;; now dispatches at each step: `start-editing-selected` to enter + ;; path edition mode, `esc-pressed` (-> :interrupt) to exit it, and + ;; `start-editing-selected` again to re-enter. + (ptk/emit! store (dws/select-shapes (d/ordered-set shape-id))) + + (ptk/emit! store (dw/start-editing-selected)) + (t/is (= shape-id (get-in @store [:workspace-local :edition])) + (str "expected " (name shape-type) " to enter path edition mode")) + + (ptk/emit! store (psc/esc-pressed)) + (t/is (nil? (get-in @store [:workspace-local :edition])) + (str "expected " (name shape-type) " to exit path edition mode")) + (t/is (= #{shape-id} (get-in @store [:workspace-local :selected])) + (str "expected " (name shape-type) " to remain selected after exiting path edition mode")) + + (ptk/emit! store (dw/start-editing-selected)) + (t/is (= shape-id (get-in @store [:workspace-local :edition])) + (str "expected " (name shape-type) " to enter path edition mode again")))) + +(t/deftest test-enter-toggles-path-editing-mode + (doseq [shape-type [:rect :circle :path :image]] + (run-scenario shape-type))) + +(t/deftest resolve-edit-fills-with-normal-parent-chain + (t/testing "resolve-edit-fills resolves fills from parent chain" + (let [objects {1 {:type :group :parent-id 2 :fills [{:fill-color "#ff0000"}]} + 2 {:type :frame :parent-id nil :fills []}} + shape {:type :path :parent-id 1 :fills []} + result (path.edition/resolve-edit-fills shape objects)] + ;; Should inherit fills from group parent + (t/is (= [{:fill-color "#ff0000"}] result))))) + +(t/deftest resolve-edit-fills-with-circular-parent-chain + (t/testing "resolve-edit-fills handles circular parent references gracefully" + (let [objects {1 {:type :rect :parent-id 2 :fills []} + 2 {:type :rect :parent-id 1 :fills []}} + shape {:type :path :parent-id 1 :fills []} + result (path.edition/resolve-edit-fills shape objects)] + ;; Should return empty fills instead of infinite loop + (t/is (= [] result))))) + +(t/deftest resolve-edit-fills-with-empty-intermediate-group + (t/testing "resolve-edit-fills traverses past empty parent groups to find fills from ancestor" + (let [objects {1 {:type :group :parent-id 2 :fills []} + 2 {:type :group :parent-id 3 :fills [{:fill-color "#00ff00"}]} + 3 {:type :frame :parent-id nil :fills []}} + shape {:type :path :parent-id 1 :fills []} + result (path.edition/resolve-edit-fills shape objects)] + ;; Should inherit fills from OuterGroup through empty InnerGroup + (t/is (= [{:fill-color "#00ff00"}] result))))) diff --git a/frontend/test/frontend_tests/data/workspace_reflow_test.cljs b/frontend/test/frontend_tests/data/workspace_reflow_test.cljs index a1a50bf985..7d3b29756d 100644 --- a/frontend/test/frontend_tests/data/workspace_reflow_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_reflow_test.cljs @@ -2,17 +2,28 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-reflow-test "Tests the reflow tasks the layout and text pipelines feed to - `app.main.data.workspace.reflow`, which is what plugin waits observe." + `app.main.data.workspace.reflow`, which is what plugin waits observe. The + promise view of the settle signal lives in `app.plugins.reflow`; these tests + use it because it is the wait the plugin API ships." (:require [app.common.uuid :as uuid] [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.shape-layout :as dwsl] + [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.texts :as dwtxt] + [app.main.data.workspace.wasm-text :as dwwt] + [app.main.fonts :as fonts] + [app.plugins.reflow :as pwrf] + [app.render-wasm.api.fonts :as wasm.fonts] + [app.util.globals :as globals] + [app.util.http :as http] + [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock] [potok.v2.core :as ptk])) (t/use-fixtures :each {:before wrf/reset-pending! @@ -43,7 +54,7 @@ (t/async done (let [store (start-pipeline!)] (ptk/emit! store (ptk/data-event :layout/update {:ids [uuid/zero]})) - (-> (wrf/wait-for-layout-update nil 20) + (-> (pwrf/wait-for-layout-update nil 20) (.then #(t/is true "resolved with no pending work")) (.catch #(t/is false "a root-only update was marked as pending work")) (.then (fn [] @@ -59,16 +70,41 @@ _ (wrf/reset-pending!) current-task (wrf/start! :text-measure [id])] (wrf/finish! stale-task) - (-> (wrf/wait-for-layout-update [id] 20) + (-> (pwrf/wait-for-layout-update [id] 20) (.then #(t/is false "a stale completion drained current work")) (.catch #(t/is true "current work stayed pending")) (.then (fn [] (wrf/finish! current-task) - (wrf/wait-for-layout-update [id] 100))) + (pwrf/wait-for-layout-update [id] 100))) (.then #(t/is true "the exact current task drained normally")) (.catch #(t/is false "the current task did not drain")) (.then (fn [] (done))))))) +(t/deftest reinstalling-the-pending-scan-resets-work-and-keeps-tracking + ;; Reinstall the pending scan with the latest reducer. + (t/async done + (let [id (uuid/next) + stale (wrf/start! :text-measure [id]) + current* (atom nil)] + (#'wrf/install-pending-subscription!) + (-> (pwrf/wait-for-layout-update [id] 100) + (.then #(t/is true "reinstalling the scan reset its previous generation")) + (.catch #(t/is false "the replaced scan kept stale work pending")) + (.then + (fn [] + (reset! current* (wrf/start! :text-measure [id])) + (pwrf/wait-for-layout-update [id] 20))) + (.then #(t/is false "the replacement scan did not track new work")) + (.catch #(t/is true "the replacement scan tracked new work")) + (.then + (fn [] + (wrf/finish! stale) + (wrf/finish! @current*) + (pwrf/wait-for-layout-update [id] 100))) + (.then #(t/is true "the replacement scan drained its exact task")) + (.catch #(t/is false "the replacement scan did not drain")) + (.then (fn [] (done))))))) + (t/deftest pending-promise-finishes-at-the-operation-boundary ;; Imperative render work is pending from before its thunk starts until the ;; exact promise returned by that thunk settles; no timer is involved. @@ -82,12 +118,12 @@ (fn [] (reset! started? true) (js/Promise. (fn [resolve _] (reset! resolve* resolve))))) - (-> (wrf/wait-for-layout-update [id] 20) + (-> (pwrf/wait-for-layout-update [id] 20) (.then #(t/is false "resolved while the render operation was pending")) (.catch #(t/is @started? "the task was opened before running the operation")) (.then (fn [] (@resolve*) - (wrf/wait-for-layout-update [id] 100))) + (pwrf/wait-for-layout-update [id] 100))) (.then #(t/is true "resolved as soon as the render operation settled")) (.catch #(t/is false "the settled render operation stayed pending")) (.then (fn [] (done))))))) @@ -98,7 +134,7 @@ (wrf/run-pending! :text-measure [id] #(throw (js/Error. "boom"))) (catch :default _)) (t/async done - (-> (wrf/wait-for-layout-update [id] 100) + (-> (pwrf/wait-for-layout-update [id] 100) (.then #(t/is true "a synchronous failure drained its exact task")) (.catch #(t/is false "a synchronous failure leaked pending work")) (.then (fn [] (done))))))) @@ -110,10 +146,10 @@ task-a (wrf/start! :text-bridge [id-a]) task-b (wrf/start! :text-bridge [id-b])] (wrf/cancel-shapes! [id-a]) - (-> (wrf/wait-for-layout-update [id-a] 100) + (-> (pwrf/wait-for-layout-update [id-a] 100) (.then #(t/is true "deleted shape work was cancelled")) (.catch #(t/is false "deleted shape work stayed pending")) - (.then #(wrf/wait-for-layout-update [id-b] 20)) + (.then #(pwrf/wait-for-layout-update [id-b] 20)) (.then #(t/is false "cancelling one shape drained its sibling")) (.catch #(t/is true "sibling work stayed pending")) (.then (fn [] @@ -130,28 +166,290 @@ (ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a id-b]})) (let [task-a (wrf/start! :text-measure [id-a])] (wrf/finish! task-a)) - (-> (wrf/wait-for-layout-update [id-b] 20) + (-> (pwrf/wait-for-layout-update [id-b] 20) (.then #(t/is false "the first text released its sibling bridge")) (.catch #(t/is true "the sibling bridge stayed pending")) (.then (fn [] (let [task-b (wrf/start! :text-measure [id-b])] (wrf/finish! task-b)) - (wrf/wait-for-layout-update [id-b] 100))) + (pwrf/wait-for-layout-update [id-b] 100))) (.then #(t/is true "the sibling drained after its own measurement")) (.catch #(t/is false "the sibling never drained")) (.then (fn [] (stop-text-pipeline! store) (done))))))) +(t/deftest text-bridge-observes-out-of-order-work + ;; Start all bridges before matching work can finish. + (t/async done + (let [store (start-text-pipeline!) + id-a (uuid/next) + id-b (uuid/next)] + (ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a id-b]})) + (let [task-b (wrf/start! :text-measure [id-b])] + (wrf/finish! task-b)) + (let [task-a (wrf/start! :text-measure [id-a])] + (wrf/finish! task-a)) + (-> (pwrf/wait-for-layout-update [id-a id-b] 100) + (.then #(t/is true "both out-of-order bridges observed their work")) + (.catch #(t/is false "a bridge missed work that started out of order")) + (.then (fn [] + (stop-text-pipeline! store) + (done))))))) + +(t/deftest text-bridge-does-not-consume-preexisting-work + ;; Ignore matching work that started before the bridge. + (t/async done + (let [store (start-text-pipeline!) + id (uuid/next) + prior-task (wrf/start! :text-measure [id])] + (ptk/emit! store (ptk/data-event :text/reflow {:ids [id]})) + (wrf/finish! prior-task) + (-> (pwrf/wait-for-layout-update [id] 20) + (.then #(t/is false "preexisting work released the new bridge")) + (.catch #(t/is true "the new bridge remained pending")) + (.then (fn [] + (let [current-task (wrf/start! :text-measure [id])] + (wrf/finish! current-task)) + (pwrf/wait-for-layout-update [id] 100))) + (.then #(t/is true "work started after the bridge drained it")) + (.catch #(t/is false "the causal measurement did not drain the bridge")) + (.then (fn [] + (stop-text-pipeline! store) + (done))))))) + +(t/deftest cancelling-a-bridge-does-not-block-later-reflow-events + (t/async done + (let [store (start-text-pipeline!) + id-a (uuid/next) + id-b (uuid/next)] + (ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a]})) + (wrf/cancel-shapes! [id-a]) + (ptk/emit! store (ptk/data-event :text/reflow {:ids [id-b]})) + (-> (pwrf/wait-for-layout-update [id-b] 20) + (.then #(t/is false "the later bridge was not opened")) + (.catch #(t/is true "the later bridge stayed pending")) + (.then (fn [] + (let [task-b (wrf/start! :text-measure [id-b])] + (wrf/finish! task-b)) + (pwrf/wait-for-layout-update [id-b] 100))) + (.then #(t/is true "the later bridge drained after its own work")) + (.catch #(t/is false "the cancelled bridge blocked the pipeline")) + (.then (fn [] + (stop-text-pipeline! store) + (done))))))) + +(t/deftest finalizing-a-page-cancels-its-text-bridges + (t/async done + (let [store (start-text-pipeline!) + id (uuid/next)] + (ptk/emit! store (ptk/data-event :text/reflow {:ids [id]})) + (ptk/emit! store (ptk/data-event :app.main.data.workspace.pages/finalize-page)) + (-> (pwrf/wait-for-layout-update [id] 100) + (.then #(t/is true "page teardown drained the unmeasured text bridge")) + (.catch #(t/is false "page teardown left text work pending")) + (.then (fn [] + (stop-text-pipeline! store) + (done))))))) + +(t/deftest failed-wasm-font-storage-falls-back-and-drains + (t/async done + (let [id (uuid/next) + font-key {:font-id "gfont-does-not-load" + :weight 400 + :style 0 + :emoji? false} + stream (rx/subject) + events (atom [])] + (->> (#'dwtxt/await-font-faces stream #{font-key} [id]) + (rx/subs! #(swap! events conj %))) + (#'wasm.fonts/report-font-storage-failed! font-key) + (-> (pwrf/wait-for-layout-update [id] 100) + (.then (fn [] + (t/is (= 1 (count @events)) + "font failure dispatches one fallback resize") + (t/is (wasm.fonts/font-ready? font-key) + "the resize gate accepts the failed face's fallback") + (done))) + (.catch (fn [_] + (t/is false "font failure leaked pending work") + (done))))))) + +(t/deftest failed-dom-font-load-falls-back-and-drains + (t/async done + (let [id (uuid/next) + font-id "gfont-layout-failure-test"] + (swap! fonts/fontsdb assoc font-id + {:id font-id + :backend :google + :family "Layout Failure Test" + :variants [{:id "regular"}]}) + (swap! fonts/loaded disj font-id) + (swap! fonts/loading dissoc font-id) + (mock/with-mocks + {globals/browser? (constantly true) + http/send! (fn [_] (rx/throw (js/Error. "font fetch failed")))} + (fn [done'] + (wrf/run-pending! :font [id] #(fonts/ensure-loaded! font-id)) + (-> (pwrf/wait-for-layout-update [id] 500) + (.then (fn [] + (t/is (not (contains? @fonts/loading font-id)) + "a failed load must not remain cached as loading"))) + (.catch #(t/is false "failed DOM font load leaked pending work")) + (.then (fn [] + (swap! fonts/fontsdb dissoc font-id) + (swap! fonts/loaded disj font-id) + (swap! fonts/loading dissoc font-id) + (done'))))) + done)))) + +(t/deftest failed-google-font-css-does-not-abort-shared-consumers + (t/async done + (let [font-id "gfont-optional-css-test" + values (atom []) + cleanup #(swap! fonts/fontsdb dissoc font-id)] + (swap! fonts/fontsdb assoc font-id + {:id font-id + :backend :google + :family "Optional CSS Test" + :variants [{:id "regular"}]}) + (mock/with-mocks + {http/send! (fn [_] (rx/throw (js/Error. "font css fetch failed")))} + (fn [done'] + (->> (fonts/fetch-font-css {:font-id font-id}) + (rx/subs! + #(swap! values conj %) + (fn [_] + (cleanup) + (t/is false "an optional font CSS failure escaped the shared helper") + (done')) + (fn [] + (cleanup) + (t/is (empty? @values) + "a failed optional font contributes no CSS") + (done'))))) + done)))) + +(t/deftest deduplicated-wasm-font-failure-settles-every-face + (t/async done + (let [font-url "https://example.test/shared-font.ttf" + regular {:font-id "gfont-shared-regular" + :weight 400 + :style 0 + :emoji? false} + bold {:font-id "gfont-shared-bold" + :weight 700 + :style 0 + :emoji? false}] + (mock/with-mocks + {http/send! (fn [_] (rx/throw (js/Error. "shared fetch failed")))} + (fn [done'] + (let [request (#'wasm.fonts/fetch-font regular font-url false false) + duplicate (#'wasm.fonts/fetch-font bold font-url false false)] + (t/is (some? request) "the first face owns the shared fetch") + (t/is (nil? duplicate) "the second face reuses the shared fetch") + (->> ((:callback request)) + (rx/subs! + (fn [_]) + (fn [_] + (t/is false "the shared fetch failure escaped its fallback") + (done')) + (fn [] + (t/is (wasm.fonts/font-ready? regular) + "the first face settled through fallback") + (t/is (wasm.fonts/font-ready? bold) + "the deduplicated face settled through fallback") + (done')))))) + done)))) + +(t/deftest missing-wasm-font-url-settles-without-entering-fetch-map + (t/async done + (let [font-data {:font-id "gfont-missing-url" + :weight 400 + :style 0 + :emoji? false}] + (t/is (nil? (#'wasm.fonts/fetch-font font-data nil false false)) + "a missing URL starts no request") + (t/is (not (contains? @wasm.fonts/fetching nil)) + "missing URLs are not deduplicated under nil") + (js/setTimeout + (fn [] + (t/is (wasm.fonts/font-ready? font-data) + "the missing face settled through fallback") + (done)) + 0)))) + +(t/deftest wasm-font-resize-waits-for-every-face + (t/async done + (let [id (uuid/next) + regular-key {:font-id "gfont-mixed" + :weight 400 + :style 0 + :emoji? false} + bold-key {:font-id "gfont-mixed" + :weight 700 + :style 0 + :emoji? false} + stream (rx/subject) + events (atom [])] + (->> (#'dwtxt/await-font-faces stream #{regular-key bold-key} [id]) + (rx/subs! #(swap! events conj %))) + (rx/push! wasm.fonts/font-stored-stream regular-key) + (-> (pwrf/wait-for-layout-update [id] 20) + (.then #(t/is false "the first face released the font task")) + (.catch #(t/is true "the second face remained pending")) + (.then (fn [] + (rx/push! wasm.fonts/font-storage-failed-stream bold-key) + (pwrf/wait-for-layout-update [id] 100))) + (.then (fn [] + (t/is (= 1 (count @events)) + "all faces settling dispatches exactly one resize") + (done))) + (.catch (fn [_] + (t/is false "the complete face set did not drain") + (done))))))) + +(t/deftest buffered-wasm-resize-releases-on-stop-without-a-commit + (t/async done + (let [store (ptk/store {:state {} :on-error #(js/console.error %)}) + id (uuid/next)] + (ptk/emit! store (dwsh/update-shapes-buffer-start)) + (ptk/emit! store (dwwt/resize-wasm-text-all [id])) + (-> (pwrf/wait-for-layout-update [id] 20) + (.then #(t/is false "the buffered resize was not marked pending")) + (.catch #(t/is true "the resize stayed pending while the buffer was open")) + (.then (fn [] + (ptk/emit! store (dwsh/update-shapes-buffer-stop)) + (pwrf/wait-for-layout-update [id] 500))) + (.then #(t/is true "buffer stop released the fallback resize")) + (.catch #(t/is false "buffer stop without a commit leaked pending work")) + (.then (fn [] (done))))))) + +(t/deftest buffered-wasm-resize-releases-on-workspace-finalize + (t/async done + (let [store (ptk/store {:state {} :on-error #(js/console.error %)}) + id (uuid/next)] + (ptk/emit! store (dwsh/update-shapes-buffer-start)) + (ptk/emit! store (dwwt/resize-wasm-text-all [id])) + (-> (pwrf/wait-for-layout-update [id] 20) + (.then #(t/is false "the buffered resize was not marked pending")) + (.catch #(t/is true "the resize stayed pending while the buffer was open")) + (.then (fn [] + (ptk/emit! store (ptk/data-event :app.main.data.workspace/finalize-workspace)) + (pwrf/wait-for-layout-update [id] 500))) + (.then #(t/is true "workspace finalization released the buffered resize")) + (.catch #(t/is false "workspace finalization leaked pending work")) + (.then (fn [] (done))))))) + (t/deftest layout-update-is-pending-until-the-buffer-flushes ;; A shape id is marked on arrival and drained when the update is processed. (t/async done (let [store (start-pipeline!)] (ptk/emit! store (ptk/data-event :layout/update {:ids [(uuid/next) uuid/zero]})) - (-> (wrf/wait-for-layout-update nil 20) + (-> (pwrf/wait-for-layout-update nil 20) (.then #(t/is false "resolved while the update was still buffered")) (.catch #(t/is true "stayed pending until the flush")) - (.then #(wrf/wait-for-layout-update nil 5000)) + (.then #(pwrf/wait-for-layout-update nil 5000)) (.then #(t/is true "resolved once the update was processed")) (.catch #(t/is false "the pipeline never drained its mark")) (.then (fn [] diff --git a/frontend/test/frontend_tests/data/workspace_shortcuts_test.cljs b/frontend/test/frontend_tests/data/workspace_shortcuts_test.cljs index efafa722ff..3d873a5260 100644 --- a/frontend/test/frontend_tests/data/workspace_shortcuts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_shortcuts_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-shortcuts-test (:require diff --git a/frontend/test/frontend_tests/data/workspace_stats_test.cljs b/frontend/test/frontend_tests/data/workspace_stats_test.cljs new file mode 100644 index 0000000000..d3df3853f6 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_stats_test.cljs @@ -0,0 +1,87 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.data.workspace-stats-test + (:require + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.types.tokens-lib :as ctob] + [app.main.data.workspace :as dw] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.state :as ths])) + +(t/use-fixtures :each + {:before cthi/reset-idmap!}) + +;; --------------------------------------------------------------------------- +;; Test compute-file-stats with various edge cases +;; --------------------------------------------------------------------------- + +(t/deftest compute-file-stats-empty-file + (t/testing "empty file with no pages" + (let [file (cthf/sample-file :file1 :page-label :page1) + store (ths/setup-store file) + state @store + file-id (:id file) + stats (dw/compute-file-stats state file-id)] + (t/is (= (:num-pages stats) 1)) + (t/is (>= (:num-shapes stats) 0)) + (t/is (>= (:avg-shapes-per-page stats) 0)) + (t/is (>= (:max-shapes-per-page stats) 0)) + (t/is (>= (:num-components stats) 0)) + (t/is (>= (:num-linked-libraries stats) 0)) + (t/is (boolean? (:is-library stats))) + (t/is (>= (:num-tokens stats) 0))))) + +(t/deftest compute-file-stats-with-shapes + (t/testing "file with shapes" + (let [file (-> (cthf/sample-file :file1 :page-label :page1) + (cthf/add-sample-shape :shape1) + (cthf/add-sample-shape :shape2)) + store (ths/setup-store file) + state @store + file-id (:id file) + stats (dw/compute-file-stats state file-id)] + (t/is (= (:num-pages stats) 1)) + (t/is (>= (:num-shapes stats) 2)) + (t/is (>= (:avg-shapes-per-page stats) 2)) + (t/is (>= (:max-shapes-per-page stats) 2))))) + +(t/deftest compute-file-stats-no-tokens + (t/testing "file with no tokens lib" + (let [file (cthf/sample-file :file1 :page-label :page1) + store (ths/setup-store file) + state @store + file-id (:id file) + stats (dw/compute-file-stats state file-id)] + (t/is (= (:num-tokens stats) 0))))) + +(t/deftest compute-file-stats-with-tokens + (t/testing "file with tokens" + (let [tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set {:name "global" + :description "Global tokens" + :tokens [{:name "color.primary" + :type :color + :value "#000000"}]})) + file (-> (cthf/sample-file :file1 :page-label :page1) + (assoc-in [:data :tokens-lib] tokens-lib)) + store (ths/setup-store file) + state @store + file-id (:id file) + stats (dw/compute-file-stats state file-id)] + (t/is (= (:num-tokens stats) 1))))) + +(t/deftest compute-file-stats-multiple-pages + (t/testing "file with multiple pages" + (let [file (-> (cthf/sample-file :file1 :page-label :page1) + (cthf/add-sample-page :page2) + (cthf/add-sample-page :page3)) + store (ths/setup-store file) + state @store + file-id (:id file) + stats (dw/compute-file-stats state file-id)] + (t/is (= (:num-pages stats) 3))))) diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index a52202bc48..8e41b36de8 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-texts-test (:require @@ -12,6 +12,7 @@ [app.common.types.modifiers :as ctm] [app.common.types.shape :as cts] [app.common.types.text :as txt] + [app.common.uuid :as uuid] [app.main.data.workspace.texts :as dwt] [app.main.ui.workspace.shapes.text.viewport-texts-html :as vth] [cljs.test :as t :include-macros true] @@ -377,6 +378,62 @@ (t/is (= "0.1" (:letter-spacing (first typographies))) "float letter-spacing is normalised to 2-decimal string"))))))) +;; --------------------------------------------------------------------------- +;; Tests: save-default-font must not persist typography refs into the global default font +;; +;; Root cause of #10925: typography assets are file-specific references, but +;; save-default-font used to write :typography-ref-id / :typography-ref-file into the +;; session-global [:workspace-global :default-font]. That state survives a file +;; switch, and v2-default-text-content bakes it into brand-new text shapes in +;; the other file, so they got a non-existent typography asset instead of the +;; default Penpot font. save-default-font now strips those two keys. +;; --------------------------------------------------------------------------- + +(t/deftest save-font-strips-typography-refs-from-default-font + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (cths/add-sample-shape :text1 + :type :text + :x 0 :y 0 + :content (txt/change-text nil "hello"))) + store (ths/setup-store file) + attrs {:font-id "roboto" + :font-family "Roboto" + :font-variant-id "regular" + :font-size "14" + :typography-ref-id (uuid/next) + :typography-ref-file (:id file)}] + (ths/run-store + store done [(dwt/save-default-font attrs)] + (fn [new-state] + (let [default-font (get-in new-state [:workspace-global :default-font])] + (t/is (some? default-font)) + (t/is (= "roboto" (:font-id default-font))) + (t/is (nil? (:typography-ref-id default-font))) + (t/is (nil? (:typography-ref-file default-font))))))))) + +(t/deftest save-font-preserves-other-font-attrs + (t/async + done + (let [store (ths/setup-store (cthf/sample-file :file1)) + attrs {:font-family "Open Sans" + :font-id "opensans" + :font-variant-id "regular" + :font-size "18" + :line-height "1.5" + :letter-spacing "0" + :typography-ref-id (uuid/next) + :typography-ref-file (uuid/next)}] + (ths/run-store store done [(dwt/save-default-font attrs)] + (fn [new-state] + (let [default-font (get-in new-state [:workspace-global :default-font])] + (t/is (= "Open Sans" (:font-family default-font))) + (t/is (= "18" (:font-size default-font))) + (t/is (= "1.5" (:line-height default-font))) + (t/is (nil? (:typography-ref-id default-font))) + (t/is (nil? (:typography-ref-file default-font))))))))) + ;; --------------------------------------------------------------------------- ;; Tests: fix-position with degenerate selrect ;; --------------------------------------------------------------------------- diff --git a/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs b/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs index 6aaa09e283..d5360b60b6 100644 --- a/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-thumbnails-test (:require diff --git a/frontend/test/frontend_tests/errors_test.cljs b/frontend/test/frontend_tests/errors_test.cljs index bff54bc113..ad93213f0b 100644 --- a/frontend/test/frontend_tests/errors_test.cljs +++ b/frontend/test/frontend_tests/errors_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.errors-test (:require diff --git a/frontend/test/frontend_tests/fonts_test.cljs b/frontend/test/frontend_tests/fonts_test.cljs index e2de0217e0..284b77635a 100644 --- a/frontend/test/frontend_tests/fonts_test.cljs +++ b/frontend/test/frontend_tests/fonts_test.cljs @@ -2,12 +2,16 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.fonts-test (:require [app.main.fonts :as fonts] - [cljs.test :as t :include-macros true])) + [app.util.globals :as globals] + [app.util.http :as http] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock])) (def sample-font {:id "sourcesanspro" @@ -124,3 +128,115 @@ result (fonts/find-closest-variant font "200" nil)] (t/is (= "200" (:weight result))) (t/is (= "italic" (:style result)))))) + +;; --- preview sprite ---------------------------------------------------------- +;; +;; The sprite feature (FLAG :font-preview) caches a pre-parsed SVG node shared by +;; every open font dropdown. `:refs` counts the open dropdowns so the node is only +;; detached when the last one closes. The unit test runner has no browser DOM, so +;; the environment boundary (`globals/browser?`) is mocked and DOM nodes are +;; replaced with minimal fakes exposing only what attach/detach touches. + +(t/use-fixtures + :each + (fn [test-fn] + (reset! fonts/preview-sprite {:status :idle :ids #{} :node nil :refs 0}) + (test-fn))) + +(defn- fake-node + "A minimal DOM-like node exposing only what the sprite attach/detach touches." + [] + #js {:remove (fn [] nil)}) + +(t/deftest attach-preview-sprite-returns-nil-while-sprite-is-not-ready + (mock/with-mocks + {globals/browser? (mock/stub (constantly true))} + (fn [done] + (reset! fonts/preview-sprite {:status :loading :ids #{} :node nil :refs 0}) + (t/is (nil? (fonts/attach-preview-sprite!))) + (t/is (= 0 (:refs @fonts/preview-sprite))) + + (reset! fonts/preview-sprite {:status :error :ids #{} :node nil :refs 0}) + (t/is (nil? (fonts/attach-preview-sprite!))) + (t/is (= 0 (:refs @fonts/preview-sprite))) + (done)) + (fn [] nil))) + +(t/deftest attach-preview-sprite-increments-refs-and-returns-the-node + (mock/with-mocks + {globals/browser? (mock/stub (constantly true))} + (fn [done] + (let [node (fake-node)] + (reset! fonts/preview-sprite {:status :ready :ids #{"a"} :node node :refs 0}) + (t/is (identical? node (fonts/attach-preview-sprite!))) + (t/is (= 1 (:refs @fonts/preview-sprite))) + (t/is (identical? node (fonts/attach-preview-sprite!))) + (t/is (= 2 (:refs @fonts/preview-sprite))) + (done))) + (fn [] nil))) + +(t/deftest detach-preview-sprite-removes-node-only-when-last-reference-drops + (mock/with-mocks + {globals/browser? (mock/stub (constantly true))} + (fn [done] + (let [removed? (volatile! false) + node #js {:remove (fn [] (vreset! removed? true))}] + (reset! fonts/preview-sprite {:status :ready :ids #{"a"} :node node :refs 0}) + (fonts/attach-preview-sprite!) + (fonts/attach-preview-sprite!) + + ;; First detach keeps the node: another dropdown is still open. + (fonts/detach-preview-sprite! node) + (t/is (= 1 (:refs @fonts/preview-sprite))) + (t/is (false? @removed?)) + + ;; Second detach reaches zero refs, so the node is removed from the DOM. + (fonts/detach-preview-sprite! node) + (t/is (= 0 (:refs @fonts/preview-sprite))) + (t/is (true? @removed?)) + (done))) + (fn [] nil))) + +(t/deftest detach-preview-sprite-clamps-refs-at-zero + (mock/with-mocks + {globals/browser? (mock/stub (constantly true))} + (fn [done] + (let [removed? (volatile! false) + node #js {:remove (fn [] (vreset! removed? true))}] + (reset! fonts/preview-sprite {:status :ready :ids #{"a"} :node node :refs 0}) + (fonts/detach-preview-sprite! node) + (t/is (= 0 (:refs @fonts/preview-sprite))) + (t/is (true? @removed?)) + (done))) + (fn [] nil))) + +(t/deftest prefetch-preview-sprite-fetches-only-from-idle-or-error + (let [calls (volatile! 0) + fetch (mock/stub (fn [& _] + (vswap! calls inc) + (rx/empty)))] + (mock/with-mocks + {globals/browser? (mock/stub (constantly true)) + http/fetch fetch} + (fn [done] + ;; :ready → no refetch + (reset! fonts/preview-sprite {:status :ready :ids #{"a"} :node (fake-node) :refs 0}) + (fonts/prefetch-preview-sprite!) + (t/is (= 0 @calls)) + + ;; :loading → no refetch (an earlier request is in flight) + (reset! fonts/preview-sprite {:status :loading :ids #{} :node nil :refs 0}) + (fonts/prefetch-preview-sprite!) + (t/is (= 0 @calls)) + + ;; :error → retries + (reset! fonts/preview-sprite {:status :error :ids #{} :node nil :refs 0}) + (fonts/prefetch-preview-sprite!) + (t/is (= 1 @calls)) + + ;; :idle → first fetch + (reset! fonts/preview-sprite {:status :idle :ids #{} :node nil :refs 0}) + (fonts/prefetch-preview-sprite!) + (t/is (= 2 @calls)) + (done)) + (fn [] nil)))) diff --git a/frontend/test/frontend_tests/helpers/events.cljs b/frontend/test/frontend_tests/helpers/events.cljs index 5fe99cad50..0cce822dbd 100644 --- a/frontend/test/frontend_tests/helpers/events.cljs +++ b/frontend/test/frontend_tests/helpers/events.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.helpers.events (:require diff --git a/frontend/test/frontend_tests/helpers/http.cljs b/frontend/test/frontend_tests/helpers/http.cljs index 00019939d1..e935073da5 100644 --- a/frontend/test/frontend_tests/helpers/http.cljs +++ b/frontend/test/frontend_tests/helpers/http.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.helpers.http "Helpers for intercepting and mocking the global `fetch` function in diff --git a/frontend/test/frontend_tests/helpers/libraries.cljs b/frontend/test/frontend_tests/helpers/libraries.cljs index 643ad0bb1e..8508a3a065 100644 --- a/frontend/test/frontend_tests/helpers/libraries.cljs +++ b/frontend/test/frontend_tests/helpers/libraries.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.helpers.libraries (:require diff --git a/frontend/test/frontend_tests/helpers/mock.cljc b/frontend/test/frontend_tests/helpers/mock.cljc index 50f4d49d29..d342c80002 100644 --- a/frontend/test/frontend_tests/helpers/mock.cljc +++ b/frontend/test/frontend_tests/helpers/mock.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.helpers.mock "Async-first mocking primitives for ClojureScript tests. diff --git a/frontend/test/frontend_tests/helpers/pages.cljs b/frontend/test/frontend_tests/helpers/pages.cljs index 874e0663c5..6ac59e6259 100644 --- a/frontend/test/frontend_tests/helpers/pages.cljs +++ b/frontend/test/frontend_tests/helpers/pages.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.helpers.pages (:require diff --git a/frontend/test/frontend_tests/helpers/state.cljs b/frontend/test/frontend_tests/helpers/state.cljs index eb7914b72c..8f891de222 100644 --- a/frontend/test/frontend_tests/helpers/state.cljs +++ b/frontend/test/frontend_tests/helpers/state.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.helpers.state (:require diff --git a/frontend/test/frontend_tests/helpers/wasm.cljs b/frontend/test/frontend_tests/helpers/wasm.cljs index 1b05e203ac..a235d83915 100644 --- a/frontend/test/frontend_tests/helpers/wasm.cljs +++ b/frontend/test/frontend_tests/helpers/wasm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.helpers.wasm "Test helpers for mocking WASM API boundary functions. diff --git a/frontend/test/frontend_tests/helpers_shapes_test.cljs b/frontend/test/frontend_tests/helpers_shapes_test.cljs index 653f7fd4e1..62fef583a2 100644 --- a/frontend/test/frontend_tests/helpers_shapes_test.cljs +++ b/frontend/test/frontend_tests/helpers_shapes_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.helpers-shapes-test (:require diff --git a/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs b/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs index 9fd0dbce46..476d549c5d 100644 --- a/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs +++ b/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.comp-remove-swap-slots-test (:require [app.common.test-helpers.components :as cthc] diff --git a/frontend/test/frontend_tests/logic/components_and_tokens.cljs b/frontend/test/frontend_tests/logic/components_and_tokens.cljs index 16370d6e5c..1be799f880 100644 --- a/frontend/test/frontend_tests/logic/components_and_tokens.cljs +++ b/frontend/test/frontend_tests/logic/components_and_tokens.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.components-and-tokens (:require [app.common.geom.point :as geom] diff --git a/frontend/test/frontend_tests/logic/copying_and_duplicating_test.cljs b/frontend/test/frontend_tests/logic/copying_and_duplicating_test.cljs index 2692d17593..7939b903df 100644 --- a/frontend/test/frontend_tests/logic/copying_and_duplicating_test.cljs +++ b/frontend/test/frontend_tests/logic/copying_and_duplicating_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.copying-and-duplicating-test (:require diff --git a/frontend/test/frontend_tests/logic/frame_guides_test.cljs b/frontend/test/frontend_tests/logic/frame_guides_test.cljs index 6f72b68c93..b3c670c7c7 100644 --- a/frontend/test/frontend_tests/logic/frame_guides_test.cljs +++ b/frontend/test/frontend_tests/logic/frame_guides_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.frame-guides-test (:require [app.common.test-helpers.compositions :as ctho] diff --git a/frontend/test/frontend_tests/logic/groups_test.cljs b/frontend/test/frontend_tests/logic/groups_test.cljs index 400a1e0283..600e076096 100644 --- a/frontend/test/frontend_tests/logic/groups_test.cljs +++ b/frontend/test/frontend_tests/logic/groups_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.groups-test (:require [app.common.data :as d] diff --git a/frontend/test/frontend_tests/logic/nudge_selected_shapes_test.cljs b/frontend/test/frontend_tests/logic/nudge_selected_shapes_test.cljs index 0d182e5fd3..570e2e69b3 100644 --- a/frontend/test/frontend_tests/logic/nudge_selected_shapes_test.cljs +++ b/frontend/test/frontend_tests/logic/nudge_selected_shapes_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.nudge-selected-shapes-test "Regression tests for the keyboard-nudge transform stream. diff --git a/frontend/test/frontend_tests/logic/pasting_in_containers_test.cljs b/frontend/test/frontend_tests/logic/pasting_in_containers_test.cljs index a0a91ea0c2..f85547ac43 100644 --- a/frontend/test/frontend_tests/logic/pasting_in_containers_test.cljs +++ b/frontend/test/frontend_tests/logic/pasting_in_containers_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.pasting-in-containers-test (:require [app.common.test-helpers.components :as cthc] diff --git a/frontend/test/frontend_tests/logic/path_actions_test.cljs b/frontend/test/frontend_tests/logic/path_actions_test.cljs new file mode 100644 index 0000000000..09451937ee --- /dev/null +++ b/frontend/test/frontend_tests/logic/path_actions_test.cljs @@ -0,0 +1,120 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.logic.path-actions-test + (:require + [app.common.geom.point :as gpt] + [app.common.types.path :as path] + [app.main.data.workspace.path.helpers :as path.helpers] + [app.main.ui.workspace.viewport.path-actions :as path.actions] + [cljs.test :as t :include-macros true] + [frontend-tests.logic.path-test-helpers :as pth])) + +(t/deftest mixed-corner-and-curve-selection-enables-both-conversions + (let [content (pth/mixed-corner-curve-content) + points (path/get-points content) + enabled (path.helpers/check-enabled content #{0 1})] + (t/is (false? (path/is-curve-point? content (first points)))) + (t/is (true? (path/is-curve-point? content (second points)))) + (t/is (true? (:make-corner enabled))) + (t/is (true? (:make-curve enabled))))) + +(t/deftest action-eligibility-keeps-coincident-node-identities + (let [content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :move-to :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 2 :c1y 2 :c2x 8 :c2y 2 :x 10 :y 0}}]) + enabled (path.helpers/check-enabled content #{0 2})] + (t/is (true? (:make-corner enabled))) + (t/is (true? (:make-curve enabled))) + (t/is (true? (:merge-nodes enabled))) + (t/is (true? (:join-nodes enabled))))) + +(t/deftest toolbar-separators-only-render-between-visible-tool-groups + (t/are [structural? shape? handler? expected] + (= expected + (path.actions/toolbar-group-visibility structural? shape? handler?)) + false false false + {:shape-handler-visible? false + :node-groups-separator-visible? false + :snap-separator-visible? false} + + true false false + {:shape-handler-visible? false + :node-groups-separator-visible? false + :snap-separator-visible? true} + + false true false + {:shape-handler-visible? true + :node-groups-separator-visible? false + :snap-separator-visible? true} + + true true false + {:shape-handler-visible? true + :node-groups-separator-visible? true + :snap-separator-visible? true} + + true false true + {:shape-handler-visible? true + :node-groups-separator-visible? true + :snap-separator-visible? true})) + +(t/deftest handler-toolbar-represents-equal-and-mixed-multi-node-modes + (let [content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 2 :c1y 0 :c2x 8 :c2y 0 :x 10 :y 0}} + {:command :curve-to + :params {:c1x 12 :c1y 0 :c2x 18 :c2y 0 :x 20 :y 0}} + {:command :curve-to + :params {:c1x 22 :c1y 0 :c2x 28 :c2y 0 :x 30 :y 0}}]) + nodes #{1 2}] + ;; Matching nodes share one active mode. + (t/is (= {:nodes #{1 2} :active-type :mirror} + (path.helpers/handler-selection-state content {} nodes))) + ;; Stored mixed modes return `:mixed`. + (t/is (= {:nodes #{1 2} :active-type :mixed} + (path.helpers/handler-selection-state content {2 :aligned} nodes))) + (t/is (= {:nodes #{1 2} :active-type :aligned} + (path.helpers/handler-selection-state + content {1 :aligned 2 :aligned} nodes))) + (t/is (= :open (path.helpers/handler-trigger-action :mixed))) + (t/is (= :select (path.helpers/handler-trigger-action :mirror))))) + +(t/deftest handler-toolbar-detects-derived-independent-and-mirror-targets + (let [content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 2 :c1y 0 :c2x 8 :c2y 0 :x 10 :y 0}} + {:command :curve-to + :params {:c1x 12 :c1y 0 :c2x 18 :c2y 5 :x 20 :y 0}}]) + node-targets #{1 2} + handler-targets (path.helpers/handler-target-nodes + content + {:nodes #{} + :segments #{} + :handlers #{[1 :c2] [2 :c2]}})] + (t/is (= :mirror (path.helpers/derive-handler-type content 1))) + (t/is (= :independent (path.helpers/derive-handler-type content 2))) + (t/is (= {:nodes #{1 2} :active-type :mixed} + (path.helpers/handler-selection-state content {} node-targets))) + (t/is (= #{1 2} handler-targets)) + (t/is (= {:nodes #{1 2} :active-type :mixed} + (path.helpers/handler-selection-state content {} handler-targets))))) + +(t/deftest opposite-handler-target-matches-handler-mode + (let [node (gpt/point 10 0) + handler (gpt/point 14 3) + opposite (gpt/point 6 0)] + (t/is (= (gpt/point 6 -3) + (path.helpers/opposite-handler-target node handler opposite :mirror))) + (t/is (= 4 + (gpt/distance + node + (path.helpers/opposite-handler-target node handler opposite :aligned)))))) + diff --git a/frontend/test/frontend_tests/logic/path_clipboard_test.cljs b/frontend/test/frontend_tests/logic/path_clipboard_test.cljs new file mode 100644 index 0000000000..5e79a7447a --- /dev/null +++ b/frontend/test/frontend_tests/logic/path_clipboard_test.cljs @@ -0,0 +1,257 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + + +(ns frontend-tests.logic.path-clipboard-test + (:require + [app.common.geom.point :as gpt] + [app.common.geom.rect :as grc] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.shapes :as cths] + [app.common.types.path :as path] + [app.main.data.workspace.path.clipboard :as path.clipboard] + [app.main.data.workspace.path.edition :as path.edition] + [app.main.data.workspace.path.helpers :as path.helpers] + [app.main.streams :as ms] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [clojure.set :as set] + [frontend-tests.helpers.state :as ths] + [frontend-tests.logic.path-test-helpers :as pth] + [potok.v2.core :as ptk])) + +(t/deftest cut-selected-nodes-copies-then-removes + ;; Cut emits copy followed by the regular delete action. + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content {:nodes #{1} :segments #{} :handlers #{}}) + events (atom []) + _ (->> (ptk/watch (path.clipboard/cut-selected-nodes) state nil) + (rx/subs! #(swap! events conj %))) + del (atom []) + _ (->> (ptk/watch (second @events) state nil) + (rx/subs! #(swap! del conj %))) + state' (ptk/update (first @del) state) + nodes (count (path/get-points (get-in state' [:workspace-drawing :object :content])))] + ;; two events emitted (copy, then the removal) + (t/is (= 2 (count @events))) + ;; The removal leaves fewer than three nodes. + (t/is (< nodes 3)))) + +(t/deftest duplicate-selection-content-copies-nodes-and-segments + (let [content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}}])] + ;; A lone node copies its incoming segment. + (let [{:keys [sub selected]} + (path.helpers/duplicate-selection-content + content {:nodes #{2} :segments #{}} (gpt/point 10 10))] + (t/is (= [[:move-to {:x 10 :y 0}] + [:line-to {:x 30 :y 10}]] + (mapv (juxt :command :params) sub))) + ;; only the new endpoint (index 1) is selected, not the attach point + (t/is (= #{1} selected))) + ;; Interior-node copies meet at one offset node. + (let [{:keys [sub selected]} + (path.helpers/duplicate-selection-content + content {:nodes #{1} :segments #{}} (gpt/point 10 10))] + (t/is (= [[:move-to {:x 0 :y 0}] + [:line-to {:x 20 :y 10}] + [:move-to {:x 20 :y 0}] + [:line-to {:x 20 :y 10}]] + (mapv (juxt :command :params) sub))) + (t/is (= #{1 3} selected))) + ;; Segment copies select both offset endpoints. + (let [{:keys [sub selected]} + (path.helpers/duplicate-selection-content + content {:nodes #{} :segments #{1}} (gpt/point 10 10))] + (t/is (= [[:move-to {:x 10 :y 10}] + [:line-to {:x 20 :y 10}]] + (mapv (juxt :command :params) sub))) + (t/is (= #{0 1} selected))))) + +(t/deftest duplicate-offset-stays-constant-in-screen-pixels + (t/is (= (gpt/point 10 10) (path.edition/duplicate-offset 1))) + (t/is (= (gpt/point 2.5 2.5) (path.edition/duplicate-offset 4))) + (t/is (= (gpt/point 20 20) (path.edition/duplicate-offset 0.5)))) + +(t/deftest splice-duplicated-appends-copies-and-selects-only-new-nodes + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}}]) + result (path.helpers/duplicate-selection-content + content {:nodes #{2} :segments #{}} (gpt/point 10 10)) + state (pth/selectable-path-state id content {:nodes #{2} :segments #{} :handlers #{}}) + state' (ptk/update (path.edition/splice-duplicated result) state) + content' (get-in state' [:workspace-drawing :object :content])] + ;; Append the copy as a new subpath. + (t/is (= 5 (count content'))) + ;; Select only the new endpoint. + (t/is (= #{4} + (get-in state' [:workspace-local :edit-path id :selection :nodes]))))) + +(t/deftest pasting-path-content-splices-and-selects-new-nodes + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content + {:nodes #{1} + :segments #{} + :handlers #{}}) + sub [{:command :move-to :params {:x 30 :y 30}} + {:command :line-to :params {:x 40 :y 30}}] + ;; Center the pasted fragment at the pointer. + _ (rx/push! ms/mouse-position (gpt/point 100 100)) + state' (ptk/update (path.clipboard/paste-content sub) state) + content' (vec (get-in state' [:workspace-drawing :object :content])) + pasted (subvec content' 3)] + (t/is (= (vec content) (subvec content' 0 3))) + (t/is (= {:x 95 :y 100} (select-keys (:params (first pasted)) [:x :y]))) + (t/is (= {:x 105 :y 100} (select-keys (:params (second pasted)) [:x :y]))) + (t/is (= {:nodes #{3 4} + :segments #{} + :handlers #{}} + (get-in state' [:workspace-local :edit-path id :selection]))))) + +(t/deftest pasting-over-identical-nodes-offsets-the-fragment + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content path.helpers/empty-selection) + ;; Same coordinates as the existing segment between nodes 0 and 1 + sub [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to :params {:c1x 2 :c1y 0 + :c2x 8 :c2y 0 + :x 10 :y 0}}] + ;; Overlapping pasted nodes receive the collision offset. + _ (rx/push! ms/mouse-position (gpt/point 5 0)) + state' (ptk/update (path.clipboard/paste-content sub) state) + content' (vec (get-in state' [:workspace-drawing :object :content])) + pasted (subvec content' 3)] + ;; Pasted nodes do not overlap existing nodes. + (t/is (= {:x 10 :y 10} (select-keys (:params (first pasted)) [:x :y]))) + (t/is (= {:x 20 :y 10} (select-keys (:params (second pasted)) [:x :y]))))) + +(t/deftest pasting-finds-a-free-offset-after-more-than-one-hundred-collisions + (let [id (random-uuid) + content (path/content + (into [{:command :move-to :params {:x 0 :y 0}}] + (map (fn [step] + {:command :line-to + :params {:x (* step 10) :y (* step 10)}})) + (range 1 101))) + state (pth/selectable-path-state id content path.helpers/empty-selection) + sub (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 1 :y 0}}]) + _ (rx/push! ms/mouse-position nil) + state' (ptk/update (path.clipboard/paste-content sub) state) + content' (get-in state' [:workspace-drawing :object :content]) + pasted (take-last 2 content')] + (t/is (= [{:x 1010 :y 1010} {:x 1011 :y 1010}] + (mapv #(select-keys (:params %) [:x :y]) pasted))) + (t/is (empty? (set/intersection + (set (path/get-points content)) + (set (path/get-points pasted))))))) + +(defn- page-paths + [state] + (->> (:objects (cthf/current-page (ths/get-file-from-state state))) + vals + (filter #(= :path (:type %))))) + +(t/deftest pasting-path-nodes-outside-editor-creates-a-new-path-shape + (t/async + done + (let [file (pth/setup-rect-file) + store (ths/setup-store file) + content (path/content [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 40 :y 0}} + {:command :line-to :params {:x 40 :y 40}}]) + target (gpt/point 300 300)] + ;; a new path shape is centred at the pointer position + (rx/push! ms/mouse-position target) + (ths/run-store + store done + [(path.clipboard/paste-nodes-as-shape content)] + (fn [new-state] + (let [paths (page-paths new-state) + pasted (first paths)] + (t/is (= 1 (count paths))) + (when pasted + (t/is (= target (grc/rect->center (:selrect pasted)))) + (t/is (contains? (get-in new-state [:workspace-local :selected]) (:id pasted)))))))))) + +(t/deftest pasting-path-nodes-while-editing-does-not-create-a-shape + (t/async + done + (let [file (pth/setup-rect-file) + id (:id (cths/get-shape file :rect1)) + store (ths/setup-store file) + content (path/content [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 40 :y 0}}]) + ;; Outside-editor paste does nothing during path editing. + events (conj (pth/start-path-edition-events id) + (path.clipboard/paste-nodes-as-shape content))] + (rx/push! ms/mouse-position (gpt/point 300 300)) + (ths/run-store + store done events + (fn [new-state] + (t/is (empty? (page-paths new-state)))))))) + +(t/deftest collision-step-with-exact-coordinates + (t/testing "collision-step detects collision with exact coordinates" + (let [pasted (gpt/point 10.0 10.0) + existing (gpt/point 20.0 20.0) + step (path.clipboard/collision-step pasted existing)] + ;; Should detect collision at step 1 + (t/is (some? step)) + (t/is (= 1 step))))) + +(t/deftest collision-step-with-floating-point-coordinates + (t/testing "collision-step detects collision with floating-point rounding differences" + (let [pasted (gpt/point 10.0 10.0) + existing (gpt/point 20.001 20.002) + step (path.clipboard/collision-step pasted existing)] + ;; x-step = 1.0001, y-step = 1.0002 + ;; With tolerance, these should be considered equal + (t/is (some? step)) + (t/is (= 1 step))))) + +(t/deftest available-offset-step-with-exact-coordinates + (t/testing "available-offset-step finds first available step with exact coordinates" + (let [existing #{(gpt/point 20.0 20.0)} + pasted #{(gpt/point 10.0 10.0)} + step (path.clipboard/available-offset-step existing pasted)] + ;; Should find step 0 (no collision at step 0) + (t/is (= 0 step))))) + +(t/deftest available-offset-step-with-floating-point-coordinates + (t/testing "available-offset-step finds first available step with floating-point rounding differences" + (let [existing #{(gpt/point 20.0001 20.0002)} + pasted #{(gpt/point 10.0001 10.0002)} + step (path.clipboard/available-offset-step existing pasted)] + ;; Should detect collision at step 1 and return step 0 as available + (t/is (= 0 step))))) + +(t/deftest collision-step-with-coordinates-slightly-below-integer + (t/testing "collision-step detects collision when coordinates drift slightly below integer boundary" + (let [pasted (gpt/point 10.0 10.0) + existing (gpt/point 19.999 19.999) + step (path.clipboard/collision-step pasted existing)] + ;; x-step = 0.9999, y-step = 0.9999 + ;; With round-based check, these should be detected as collision at step 1 + (t/is (some? step)) + (t/is (= 1 step))))) + +(t/deftest available-offset-step-with-coordinates-slightly-below-integer + (t/testing "available-offset-step detects collision with sub-integer coordinate drift" + (let [existing #{(gpt/point 19.999 19.999)} + pasted #{(gpt/point 10.0 10.0)} + step (path.clipboard/available-offset-step existing pasted)] + ;; Should detect collision at step 1 and return step 0 as available + (t/is (= 0 step))))) diff --git a/frontend/test/frontend_tests/logic/path_helpers_test.cljs b/frontend/test/frontend_tests/logic/path_helpers_test.cljs new file mode 100644 index 0000000000..ba0c28038f --- /dev/null +++ b/frontend/test/frontend_tests/logic/path_helpers_test.cljs @@ -0,0 +1,117 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + + +(ns frontend-tests.logic.path-helpers-test + (:require + [app.common.geom.point :as gpt] + [app.common.types.path :as path] + [app.main.data.workspace.path.helpers :as path.helpers] + [app.main.data.workspace.path.streams :as path.streams] + [app.main.store :as st] + [cljs.test :as t :include-macros true] + [frontend-tests.logic.path-test-helpers :as pth])) + +(t/deftest to-pixel-snap-quantises-to-half-pixels-past-the-zoom-threshold + ;; Pixel snapping uses half steps above 300% zoom. + (let [original @st/state + snap (fn [zoom p] + (reset! st/state {:workspace-layout #{:snap-pixel-grid} + :workspace-local {:zoom zoom}}) + (path.streams/to-pixel-snap p))] + (try + ;; at or below the threshold, snapping rounds to whole pixels + (t/is (= (gpt/point 100 100) (snap 1 (gpt/point 100.4 100.4)))) + (t/is (= (gpt/point 100 100) (snap 3 (gpt/point 100.4 100.4)))) + ;; above 300% zoom it rounds to the nearest half pixel + (t/is (= (gpt/point 100.5 100.5) (snap 6 (gpt/point 100.4 100.4)))) + ;; with pixel snapping off the position passes through unchanged + (reset! st/state {:workspace-layout #{} :workspace-local {:zoom 6}}) + (t/is (= (gpt/point 100.4 100.4) + (path.streams/to-pixel-snap (gpt/point 100.4 100.4)))) + (finally + (reset! st/state original))))) + +(t/deftest node-merge-snap-finds-the-closest-target-for-multiple-moving-points + (let [start-point (gpt/point 0 0) + selected-points #{start-point (gpt/point 100 0)} + points (into selected-points + [(gpt/point 8 14) + (gpt/point 111 10.5) + (gpt/point 500 500)]) + snap-position (path.streams/make-node-merge-snap + start-point selected-points points 10)] + ;; The closest merge target moves the full selection. + (t/is (= (gpt/point 11 10.5) + (snap-position (gpt/point 10 10)))) + ;; Missing merge targets return no snap delta. + (t/is (nil? (snap-position (gpt/point 300 300)))))) + +(t/deftest insertion-preview-reuses-precomputed-segment-midpoints + (let [content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :curve-to + :params {:c1x 10 :c1y 0 + :c2x 20 :c2y 10 + :x 20 :y 0}} + {:command :close-path :params {}}]) + midpoints (path.helpers/insertion-mid-points content) + line-mid (first midpoints) + curve-mid (second midpoints)] + (t/is (= 2 (count midpoints))) + (t/is (= (gpt/point 5 0) line-mid)) + (t/is (= {:from-p (gpt/point 0 0) + :to-p (gpt/point 10 0) + :t 0.5} + (meta line-mid))) + (t/is (= line-mid + (path.helpers/insertion-point + content (gpt/point 5.5 0) 1 false midpoints))) + (t/is (nil? (path.helpers/insertion-point + content (gpt/point 200 200) 1 false midpoints))) + ;; Alt/insert-anywhere remains dynamic and ignores the midpoint cache. + (t/is (some? (path.helpers/insertion-point + content curve-mid 1 true []))))) + +(t/deftest selected-node-indices-folds-segment-endpoints + (let [content (pth/selectable-path-content)] + ;; segment index 1 connects nodes 0 and 1 + (t/is (= #{0 1} + (path.helpers/selected-node-indices content {:nodes #{} :segments #{1}}))) + ;; explicit nodes and segment endpoints are unioned + (t/is (= #{0 1 2} + (path.helpers/selected-node-indices content {:nodes #{2} :segments #{1}}))))) + +(t/deftest remap-selection-follows-content-structure + (let [content (pth/selectable-path-content) + ;; Same command layout: index 1 turned into a line-to + corner (path/content + (assoc (vec content) 1 {:command :line-to + :params {:x 10 :y 0}})) + ;; Different layout: the middle node was removed + shorter (path/content + [{:command :move-to + :params {:x 0 :y 0}} + {:command :line-to + :params {:x 20 :y 0}}]) + selection {:nodes #{1 2} + :segments #{2} + :handlers #{[1 :c1] [2 :c2]}}] + (t/is (= {:nodes #{1 2} + :segments #{2} + :handlers #{[2 :c2]}} + (path.helpers/remap-selection selection content corner))) + (t/is (= {:nodes #{1} + :segments #{} + :handlers #{}} + (path.helpers/remap-selection selection content shorter))))) + +(t/deftest handlers-joined-detects-smooth-vs-corner-nodes + ;; node (10,0): incoming [1 :c2]=(8,0), outgoing [2 :c1] + (t/is (path.helpers/handlers-joined? (pth/selectable-path-content) 2 :c1)) + (t/is (not (path.helpers/handlers-joined? (pth/corner-path-content) 2 :c1)))) + diff --git a/frontend/test/frontend_tests/logic/path_lifecycle_test.cljs b/frontend/test/frontend_tests/logic/path_lifecycle_test.cljs new file mode 100644 index 0000000000..66b20090c8 --- /dev/null +++ b/frontend/test/frontend_tests/logic/path_lifecycle_test.cljs @@ -0,0 +1,542 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + + +(ns frontend-tests.logic.path-lifecycle-test + (:require + [app.common.data.undo-stack :as u] + [app.common.geom.point :as gpt] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.shapes :as cths] + [app.common.types.path :as path] + [app.main.data.workspace.drawing.common :as dwdc] + [app.main.data.workspace.edition :as dwe] + [app.main.data.workspace.path.changes :as path.changes] + [app.main.data.workspace.path.common :as path.common] + [app.main.data.workspace.path.drawing :as path.drawing] + [app.main.data.workspace.path.edition :as path.edition] + [app.main.data.workspace.path.helpers :as path.helpers] + [app.main.data.workspace.path.selection :as path.selection] + [app.main.data.workspace.path.shortcuts :as path.shortcuts] + [app.main.data.workspace.path.state :as path.state] + [app.main.data.workspace.path.streams :as path.streams] + [app.main.data.workspace.path.tools :as path.tools] + [app.main.data.workspace.path.undo :as path.undo] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.state :as ths] + [frontend-tests.logic.path-test-helpers :as pth] + [potok.v2.core :as ptk])) + +(t/deftest path-lifecycle-selectors-use-the-active-path + (let [id (random-uuid) + edit-state {:edit-mode :draw} + edit-path {id edit-state} + state {:workspace-local {:edition id + :edit-path edit-path} + :workspace-drawing {:object {:id id :type :path}}}] + (t/is (= edit-state (path.state/current-edit-state state))) + (t/is (= edit-state (path.state/current-edit-state edit-path id))) + (t/is (path.state/editing? state)) + (t/is (path.state/editing? edit-path id)) + (t/is (path.state/drawing? edit-state id :path {:id id :type :path})))) + +(t/deftest path-drawing-selector-finds-new-paths + (let [id (random-uuid) + state {:workspace-local {:edition nil + :edit-path {id {}}} + :workspace-drawing {:tool :path + :object {:id id :type :path}}}] + (t/is (path.state/drawing? state)) + (t/is (not (path.state/editing? state))) + (t/is (path.state/drawing? nil nil :path {:id id :type :path})) + (t/is (not (path.state/drawing? nil nil :curve {:id id :type :path}))))) + +(t/deftest clear-edition-mode-finishes-path-streams + (t/is (path.streams/finish-edition? (dwe/clear-edition-mode))) + (t/is (not (path.streams/finish-edition? :interrupt)))) + +(t/deftest clear-edition-mode-finishes-active-path-before-finalizing + (let [id (random-uuid) + event (dwe/clear-edition-mode) + state {:workspace-local {:edition id + :edit-path {id {:edit-mode :move}}} + :workspace-drawing {:object {:id id}}} + state' (ptk/update event state) + emissions (atom [])] + (->> (ptk/watch event state' nil) + (rx/subs! #(swap! emissions conj %))) + (t/is (nil? (get-in state' [:workspace-local :edition]))) + (t/is (some? (get-in state' [:workspace-local :edit-path id]))) + (t/is (= [::path.common/finish-path] + (mapv ptk/type @emissions))))) + +(t/deftest clear-non-path-edition-does-not-emit-finish-path + (let [id (random-uuid) + event (dwe/clear-edition-mode) + state {:workspace-local {:edition id}} + state' (ptk/update event state) + emissions (atom [])] + (->> (ptk/watch event state' nil) + (rx/subs! #(swap! emissions conj %))) + (t/is (nil? (get-in state' [:workspace-local :edition]))) + (t/is (empty? @emissions)))) + +(t/deftest restarting-draw-mode-finishes-pending-subpath + (let [file (pth/setup-rect-file) + shape (-> (cths/get-shape file :rect1) + (path/convert-to-path)) + id (:id shape) + last-point (last (path/get-points (:content shape))) + state {:workspace-local + {:edition id + :edit-path + {id {:edit-mode :draw + :last-point last-point + :preview {:command :line-to + :params {:x 150 :y 150}} + :old-content (:content shape)}}} + :workspace-drawing {:object shape}} + stream (rx/subject) + emissions (atom [])] + (->> (ptk/watch (path.drawing/start-draw-mode*) state stream) + (rx/take 4) + (rx/subs! #(swap! emissions conj %))) + (rx/push! stream (ptk/data-event ::path.drawing/end-edition + {:restart? true})) + (t/is (= [::path.drawing/start-edition + ::path.common/finish-path + ::path.drawing/check-changed-content + ::path.drawing/start-draw-mode*] + (mapv ptk/type @emissions))) + (let [state' (ptk/update (second @emissions) state) + state'' (ptk/update (path.drawing/preview-next-point + {:x 200 :y 200}) + state')] + (t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point]))) + (t/is (nil? (get-in state' [:workspace-local :edit-path id :preview]))) + (t/is (= :move-to + (get-in state'' [:workspace-local :edit-path id :preview :command])))))) + +(t/deftest escape-does-not-restart-edited-path-draw-loop + (let [id (random-uuid) + state {:workspace-local + {:edition id + :edit-path {id {:edit-mode :draw}}}} + stream (rx/subject) + emissions (atom [])] + (t/is (path.drawing/restart-draw-loop? (path.common/finish-path))) + (->> (ptk/watch (path.drawing/start-draw-mode*) state stream) + (rx/subs! #(swap! emissions conj %))) + (rx/push! stream (ptk/data-event ::path.drawing/end-edition + {:restart? false})) + (t/is (= [::path.drawing/start-edition] + (mapv ptk/type @emissions))))) + +(defn- run-handle-drawing-end + "Runs the draw-ending flow and passes its events to `callback`." + [restart? callback] + (let [state (pth/drawing-path-state) + stream (rx/subject) + emissions (atom [])] + (->> (ptk/watch (path.drawing/handle-drawing) state stream) + (rx/subs! #(swap! emissions conj %))) + (rx/push! stream (ptk/data-event ::path.drawing/end-edition + {:restart? restart?})) + ;; Wait for the asynchronous drawing-end event. + (js/setTimeout + (fn [] + (let [end-event (last @emissions) + end-emissions (atom [])] + (->> (ptk/watch end-event state stream) + (rx/subs! #(swap! end-emissions conj %))) + (callback @end-emissions)))))) + +(t/deftest escape-ending-new-path-draw-does-not-reenter-edition + (t/async + done + (run-handle-drawing-end + false + (fn [emissions] + (t/is (= [::path.drawing/close-drawn-loops + ::path.drawing/setup-frame + ::dwdc/handle-finish-drawing + ::dwe/clear-edition-mode] + (mapv ptk/type emissions))) + (done))))) + +(t/deftest finishing-new-path-draw-reenters-edition + (t/async + done + (run-handle-drawing-end + true + (fn [emissions] + (t/is (= [::path.common/finish-path + ::path.drawing/close-drawn-loops + ::path.drawing/setup-frame + ::dwdc/handle-finish-drawing + ::path.drawing/start-created-path-edition] + (mapv ptk/type emissions))) + (done))))) + +(t/deftest escape-with-pending-segment-cancels-it-and-keeps-drawing + (let [id (random-uuid) + state {:workspace-local + {:edition id + :edit-path {id {:edit-mode :draw + :last-point (gpt/point 10 10) + :preview {:command :line-to + :params {:x 20 :y 20}}}}}} + emissions (atom [])] + (->> (ptk/watch (path.shortcuts/esc-pressed) state nil) + (rx/subs! #(swap! emissions conj %))) + (t/is (= [::path.common/cancel-pending-segment] + (mapv ptk/type @emissions))) + (let [state' (ptk/update (first @emissions) state)] + (t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point]))) + (t/is (nil? (get-in state' [:workspace-local :edit-path id :preview]))) + (t/is (= :draw (get-in state' [:workspace-local :edit-path id :edit-mode])))))) + +(t/deftest escape-while-creating-path-finishes-it-into-edition + (let [id (random-uuid) + state {:workspace-local + {:edit-path {id {:edit-mode :draw + :last-point (gpt/point 10 10)}}} + :workspace-drawing {:object {:id id :type :path}}} + emissions (atom [])] + (->> (ptk/watch (path.shortcuts/esc-pressed) state nil) + (rx/subs! #(swap! emissions conj %))) + ;; Finishing creates the shape and clears its pending segment. + (t/is (= [::path.common/finish-path] + (mapv ptk/type @emissions))))) + +(t/deftest escape-without-pending-segment-interrupts-edition + (let [id (random-uuid) + state {:workspace-local + {:edition id + :edit-path {id {:edit-mode :draw}}}} + emissions (atom [])] + (->> (ptk/watch (path.shortcuts/esc-pressed) state nil) + (rx/subs! #(swap! emissions conj %))) + (t/is (= [:interrupt] @emissions)))) + +(t/deftest editing-path-only-updates-drawing-copy + (t/async + done + (let [file (pth/setup-rect-file) + original-rect (cths/get-shape file :rect1) + id (:id original-rect) + delta (gpt/point 10 5) + store (ths/setup-store file) + events (conj (pth/start-path-edition-events id) + (pth/move-drawing-content delta))] + (ths/run-store + store done events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + stored-shape (cths/get-shape file' :rect1) + drawing-copy (get-in new-state [:workspace-drawing :object])] + (t/is (= original-rect stored-shape)) + (t/is (= :path (:type drawing-copy))) + (t/is (= (path/move-content + (:content (path/convert-to-path original-rect)) + delta) + (:content drawing-copy))))))))) + +(t/deftest unchanged-path-edition-preserves-simple-shape + (t/async + done + (let [file (pth/setup-rect-file) + original-rect (cths/get-shape file :rect1) + id (:id original-rect) + store (ths/setup-store file) + events (conj (pth/start-path-edition-events id) :interrupt)] + (ths/run-store + store done events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + stored-shape (cths/get-shape file' :rect1)] + (t/is (= original-rect stored-shape)) + (t/is (= :rect (:type stored-shape))) + (t/is (nil? (get-in new-state [:workspace-drawing :object]))))))))) + +(t/deftest changed-path-edition-is-persisted-when-finalized + (t/async + done + (let [file (pth/setup-rect-file) + original-rect (cths/get-shape file :rect1) + id (:id original-rect) + delta (gpt/point 10 5) + original-path (path/convert-to-path original-rect) + ;; Persist the rectangle with an explicit close command. + changed-content (-> (:content original-path) + (path/move-content delta) + (path/close-loops)) + expected-shape (-> original-path + (assoc :content changed-content) + (path/update-geometry)) + store (ths/setup-store file) + events (into (pth/start-path-edition-events id) + [(pth/move-drawing-content delta) + :interrupt])] + (ths/run-store + store done events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + stored-shape (cths/get-shape file' :rect1)] + (t/is (= expected-shape stored-shape)) + (t/is (= :path (:type stored-shape))) + (t/is (nil? (get-in new-state [:workspace-drawing :object]))))))))) + +(t/deftest created-path-edition-cleans-drawing-state-on-exit + (t/async + done + (let [file (pth/setup-rect-file) + original-rect (cths/get-shape file :rect1) + id (:id original-rect) + store (ths/setup-store file) + events [(path.drawing/start-created-path-edition id) + :interrupt]] + (ths/run-store + store done events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + stored-shape (cths/get-shape file' :rect1)] + (t/is (= original-rect stored-shape)) + (t/is (nil? (get-in new-state [:workspace-local :edition]))) + (t/is (nil? (get-in new-state [:workspace-local :edit-path id]))) + (t/is (nil? (get-in new-state [:workspace-drawing :object]))))))))) + +(defn- with-dangling-subpath-start + [content] + (path/content (conj (vec content) + {:command :move-to + :params {:x 30 :y 40}}))) + +(t/deftest cancel-pending-segment-drops-dangling-subpath-start + (let [id (random-uuid) + content (pth/selectable-path-content) + state {:workspace-local + {:edition id + :edit-path {id {:edit-mode :draw + :last-point (gpt/point 30 40) + :preview {:command :line-to + :params {:x 50 :y 50}}}}} + :workspace-drawing + {:object {:id id + :type :path + :content (with-dangling-subpath-start content)}}} + state' (ptk/update (path.common/cancel-pending-segment) state)] + (t/is (= (vec content) + (vec (get-in state' [:workspace-drawing :object :content])))) + (t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point]))) + (t/is (nil? (get-in state' [:workspace-local :edit-path id :preview]))))) + +(t/deftest finish-path-drops-dangling-subpath-start + (let [id (random-uuid) + content (pth/selectable-path-content) + state {:workspace-local + {:edition id + :edit-path {id {:edit-mode :draw + :last-point (gpt/point 30 40)}}} + :workspace-drawing + {:object {:id id + :type :path + :content (with-dangling-subpath-start content)}}} + state' (ptk/update (path.common/finish-path) state)] + (t/is (= (vec (path/close-subpaths content)) + (vec (get-in state' [:workspace-drawing :object :content])))) + (t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point]))))) + +(t/deftest finalize-ignores-dangling-subpath-start + (let [file (pth/setup-rect-file) + rect (cths/get-shape file :rect1) + id (:id rect) + path-shape (path/convert-to-path rect) + old-content (:content path-shape) + state {:current-file-id (:id file) + :current-page-id (cthf/current-page-id file) + :files {(:id file) file} + :workspace-local + {:edition id + :edit-path {id {:old-content old-content}}} + :workspace-drawing + {:object (assoc path-shape + :content + (with-dangling-subpath-start old-content))}} + emissions (atom [])] + (->> (ptk/watch (path.changes/finalize-path-content id) state nil) + (rx/subs! #(swap! emissions conj %))) + (t/is (empty? @emissions)))) + +(t/deftest next-point-preview-is-suppressed-during-a-modifier-drag + (let [id (random-uuid) + content (path/content [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 100 :y 0}}]) + mk (fn [modifiers] + ;; Draw mode keeps the path in the drawing object. + {:workspace-drawing {:object {:id id :type :path :content content}} + :workspace-local {:edition nil + :zoom 1 + :edit-path {id {:edit-mode :draw + :last-point (gpt/point 100 0) + :content-modifiers modifiers}}}}) + event (path.drawing/preview-next-point {:x 150 :y 40 :shift? false}) + idle (ptk/update event (mk {})) + during (ptk/update event (mk {1 {:c1x 5 :c1y 5}}))] + ;; no active drag: the next-point preview updates as usual + (t/is (some? (get-in idle [:workspace-local :edit-path id :preview]))) + ;; a placed handler is being dragged mid-draw: the preview must not move + (t/is (nil? (get-in during [:workspace-local :edit-path id :preview]))))) + +(t/deftest dragging-the-current-curve-forward-handle-while-drawing + (let [id (random-uuid) + content (path/content [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to + :params {:x 100 :y 0 :c1x 30 :c1y 0 :c2x 70 :c2y 0}}]) + mk (fn [] + ;; Store the backward handle and transient forward handle. + {:workspace-drawing {:object {:id id :type :path :content content}} + :workspace-local {:edition nil + :zoom 1 + :edit-path {id {:edit-mode :draw + :last-point (gpt/point 100 0) + :prev-handler (gpt/point 130 0)}}}}) + drag (fn [alt?] + (ptk/update (path.drawing/drag-prev-handler + {:x 100 :y 50 :alt? alt? :shift? false}) + (mk))) + finish (fn [state] (ptk/update (path.drawing/finish-drag) state)) + c2-of (fn [state] + (-> (get-in state [:workspace-drawing :object :content]) + (vec) (nth 1) :params (select-keys [:c2x :c2y])))] + + (t/testing "no alt: the forward handle follows the pointer and the committed backward handle mirrors it" + (let [dragged (drag false)] + ;; the forward handle tracks the pointer + (t/is (= (gpt/point 100 50) + (get-in dragged [:workspace-local :edit-path id :drag-handler]))) + ;; the stale transient forward handle is cleared so it is not double-rendered + (t/is (nil? (get-in dragged [:workspace-local :edit-path id :prev-handler]))) + (let [committed (finish dragged)] + ;; c2 = 2*node - forward = (100,-50) + (t/is (= {:c2x 100 :c2y -50} (c2-of committed))) + ;; the new forward handle becomes the prev-handler + (t/is (= (gpt/point 100 50) + (get-in committed [:workspace-local :edit-path id :prev-handler])))))) + + (t/testing "alt: the forward handle moves on its own, the backward handle stays put" + (let [committed (finish (drag true))] + (t/is (= {:c2x 70 :c2y 0} (c2-of committed))))))) + +(t/deftest dragging-the-current-curve-backward-handle-while-drawing + ;; Dragging the backward handle mirrors the transient forward handle. + (let [id (random-uuid) + content (path/content [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to + :params {:x 100 :y 0 :c1x 30 :c1y 0 :c2x 70 :c2y 0}}]) + mk (fn [] + {:workspace-drawing {:object {:id id :type :path :content content}} + :workspace-local {:edition nil + :zoom 1 + :edit-path {id {:edit-mode :draw + :last-point (gpt/point 100 0) + :prev-handler (gpt/point 130 0)}}}}) + ;; Drag the backward handle to `(70, -40)`. + drag (fn [mode] + (ptk/update (path.edition/modify-selected-handlers + id [1 :c2] {} 0 -40 mode (= mode :smart)) + (mk))) + prev-of (fn [state] (get-in state [:workspace-local :edit-path id :prev-handler]))] + + (t/testing "smart (no modifier): the forward handle mirrors the angle, keeping its own length" + ;; Keep the forward handle's length while mirroring its angle. + (t/is (= (gpt/point 118 24) (prev-of (drag :smart))))) + + (t/testing "mirror (mod): the forward handle full-mirrors to equal length" + ;; Mirror the forward handle around the node. + (t/is (= (gpt/point 130 40) (prev-of (drag :mirror))))) + + (t/testing "independent (alt): the forward handle is left untouched" + (t/is (= (gpt/point 130 0) (prev-of (drag :independent))))))) + +(t/deftest path-local-undo-redo-restores-content-and-clears-preview + (let [id (random-uuid) + content-a (path/content [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}}]) + content-b (path/content [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 5}}]) + base (-> (pth/selectable-path-state id content-a path.helpers/empty-selection) + (assoc-in [:workspace-local :edit-path id :undo-stack] (u/make-stack))) + ;; Capture both content states around a stale preview. + s1 (ptk/update (path.undo/add-undo-entry) base) + s2 (-> (path.state/set-content s1 content-b) + (assoc-in [:workspace-local :edit-path id :preview] + {:command :line-to :params {:x 99 :y 99}})) + s3 (ptk/update (path.undo/add-undo-entry) s2) + s4 (ptk/update (path.undo/undo-path) s3) + s5 (ptk/update (path.undo/redo-path) s4)] + (t/is (= content-b (path.state/get-path s3 :content))) + (t/is (= content-a (path.state/get-path s4 :content))) + ;; Restoring an entry drops its render-only preview. + (t/is (nil? (get-in s4 [:workspace-local :edit-path id :preview]))) + (t/is (= content-b (path.state/get-path s5 :content))))) + +(t/deftest path-undo-entry-never-captures-the-transient-preview + (let [id (random-uuid) + state (-> (pth/selectable-path-state id (pth/selectable-path-content) + path.helpers/empty-selection) + (assoc-in [:workspace-local :edit-path id :undo-stack] (u/make-stack)) + (assoc-in [:workspace-local :edit-path id :preview] + {:command :line-to :params {:x 99 :y 99}})) + state' (ptk/update (path.undo/add-undo-entry) state) + entry (u/peek (get-in state' [:workspace-local :edit-path id :undo-stack]))] + (t/is (some? entry)) + (t/is (not (contains? entry :preview))))) + +;; Tool operations through the full edition lifecycle. + +(t/deftest tool-make-curve-persists-through-edition-lifecycle + (t/async + done + (let [file (pth/setup-rect-file) + rect (cths/get-shape file :rect1) + id (:id rect) + store (ths/setup-store file) + events (into (pth/start-path-edition-events id) + [(path.selection/select-node 1 false) + (path.tools/make-curve) + :interrupt])] + (ths/run-store + store done events + (fn [new-state] + (let [stored (cths/get-shape (ths/get-file-from-state new-state) :rect1)] + (t/is (= :path (:type stored))) + ;; make-curve on a corner introduces at least one curve segment + (t/is (some #(= :curve-to (:command %)) (seq (:content stored)))) + (t/is (nil? (get-in new-state [:workspace-drawing :object]))))))))) + +(t/deftest tool-remove-node-persists-through-edition-lifecycle + (t/async + done + (let [file (pth/setup-rect-file) + rect (cths/get-shape file :rect1) + id (:id rect) + orig-nodes (count (path/get-points (:content (path/convert-to-path rect)))) + store (ths/setup-store file) + events (into (pth/start-path-edition-events id) + [(path.selection/select-node 1 false) + (path.tools/remove-node) + :interrupt])] + (ths/run-store + store done events + (fn [new-state] + (let [stored (cths/get-shape (ths/get-file-from-state new-state) :rect1)] + (t/is (= :path (:type stored))) + ;; removing a node leaves fewer nodes than the converted rect had + (t/is (< (count (path/get-points (:content stored))) orig-nodes)) + (t/is (nil? (get-in new-state [:workspace-drawing :object]))))))))) + diff --git a/frontend/test/frontend_tests/logic/path_test_helpers.cljs b/frontend/test/frontend_tests/logic/path_test_helpers.cljs new file mode 100644 index 0000000000..d55fcd17a4 --- /dev/null +++ b/frontend/test/frontend_tests/logic/path_test_helpers.cljs @@ -0,0 +1,94 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + + +(ns frontend-tests.logic.path-test-helpers + (:require + [app.common.test-helpers.compositions :as ctho] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.shapes :as cths] + [app.common.types.path :as path] + [app.main.data.workspace.edition :as dwe] + [app.main.data.workspace.path.edition :as path.edition] + [app.main.data.workspace.path.state :as path.state])) + +(defn setup-rect-file + [] + (ctho/add-rect (cthf/sample-file :file1) + :rect1 + :x 10 + :y 20 + :width 100 + :height 80)) + +(defn start-path-edition-events + [id] + [(dwe/start-edition-mode id) + (path.edition/start-path-edit id)]) + +(defn move-drawing-content + [delta] + (fn [state] + (path.state/set-content + state + (path/move-content (path.state/get-path state :content) delta)))) + +(defn drawing-path-state + [] + (let [file (setup-rect-file) + shape (-> (cths/get-shape file :rect1) + (path/convert-to-path))] + {:workspace-drawing {:object shape}})) + +(defn selectable-path-content + [] + (path/content + [{:command :move-to + :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 2 :c1y 0 + :c2x 8 :c2y 0 + :x 10 :y 0}} + {:command :curve-to + :params {:c1x 12 :c1y 0 + :c2x 18 :c2y 0 + :x 20 :y 0}}])) + +(defn selectable-path-state + [id content selection] + {:workspace-local {:edition id + :edit-path {id {:selection selection}}} + :workspace-drawing {:object {:id id + :type :path + :content content}}}) + +(defn mixed-corner-curve-content + [] + (path/content + [{:command :move-to + :params {:x 0 :y 0}} + {:command :line-to + :params {:x 10 :y 0}} + {:command :curve-to + :params {:c1x 12 :c1y 4 + :c2x 18 :c2y 4 + :x 20 :y 0}}])) + +(defn corner-path-content + "Returns selectable content with a corner at `(10, 0)`." + [] + (path/content + [{:command :move-to + :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 2 :c1y 0 + :c2x 8 :c2y 0 + :x 10 :y 0}} + {:command :curve-to + :params {:c1x 12 :c1y 6 + :c2x 18 :c2y 0 + :x 20 :y 0}}])) + diff --git a/frontend/test/frontend_tests/logic/path_tools_test.cljs b/frontend/test/frontend_tests/logic/path_tools_test.cljs new file mode 100644 index 0000000000..fd286ff728 --- /dev/null +++ b/frontend/test/frontend_tests/logic/path_tools_test.cljs @@ -0,0 +1,804 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + + +(ns frontend-tests.logic.path-tools-test + (:require + [app.common.geom.point :as gpt] + [app.common.geom.rect :as grc] + [app.common.types.path :as path] + [app.main.data.workspace.path.drawing :as path.drawing] + [app.main.data.workspace.path.edition :as path.edition] + [app.main.data.workspace.path.helpers :as path.helpers] + [app.main.data.workspace.path.selection :as path.selection] + [app.main.data.workspace.path.state :as path.state] + [app.main.data.workspace.path.tools :as path.tools] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.logic.path-test-helpers :as pth] + [potok.v2.core :as ptk])) + +(t/deftest mixed-node-conversions-only-change-opposite-node-type + (let [id (random-uuid) + content (pth/mixed-corner-curve-content) + points (path/get-points content) + corner-point (first points) + curve-point (second points) + state (pth/selectable-path-state + id content + {:nodes #{0 1} :segments #{} :handlers #{}}) + curved-state (ptk/update (path.tools/make-curve) state) + cornered-state (ptk/update (path.tools/make-corner) state) + curved-content (path.state/get-path curved-state :content) + corner-content (path.state/get-path cornered-state :content)] + (t/is (path/is-curve-point? curved-content corner-point)) + (t/is (= (nth content 2) (nth curved-content 2))) + (t/is (not (path/is-curve-point? corner-content corner-point))) + (t/is (not (path/is-curve-point? corner-content curve-point))))) + +(t/deftest plain-and-shift-selection-work-across-path-element-types + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state + id content + {:nodes #{0} + :segments #{1} + :handlers #{[1 :c1]}}) + state' (ptk/update (path.selection/select-handler 1 :c2 false) state) + state'' (ptk/update (path.selection/select-segment 2 true) state') + state''' (ptk/update (path.selection/select-handler 1 :c2 true) state'') + state'''' (ptk/update (path.selection/select-handler 2 :c1 true) state''')] + (t/is (= {:nodes #{} + :segments #{} + :handlers #{[1 :c2]}} + (get-in state' [:workspace-local :edit-path id :selection]))) + (t/is (= {:nodes #{} + :segments #{2} + :handlers #{[1 :c2]}} + (get-in state'' [:workspace-local :edit-path id :selection]))) + (t/is (= {:nodes #{} + :segments #{2} + :handlers #{[2 :c1]}} + (get-in state'''' [:workspace-local :edit-path id :selection]))))) + +(t/deftest path-area-selection-prioritizes-nodes-over-segments-over-handlers + (let [id (random-uuid) + content (pth/selectable-path-content) + selection path.helpers/empty-selection + state (pth/selectable-path-state id content selection) + ;; Contains segment 1 and handler [1 :c1] but no node + rect (grc/make-rect 1 -2 3 4) + state' (ptk/update (path.selection/select-path-area + rect selection false) + state) + ;; Contains node 0, segment 1 and handler [1 :c1] + node-rect (grc/make-rect -1 -1 4 2) + state'' (ptk/update (path.selection/select-path-area + node-rect selection false) + state)] + (t/is (= {:nodes #{} + :segments #{1} + :handlers #{}} + (get-in state' [:workspace-local :edit-path id :selection]))) + (t/is (= {:nodes #{0} + :segments #{} + :handlers #{}} + (get-in state'' [:workspace-local :edit-path id :selection]))))) + +(t/deftest path-area-selection-picks-handlers-only-when-nothing-else-is-inside + (let [id (random-uuid) + ;; Curve bulging up to y 7.5 with both handlers on y 10, away + ;; from the curve itself + content (path/content + [{:command :move-to + :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 0 :c1y 10 + :c2x 10 :c2y 10 + :x 10 :y 0}}]) + selection path.helpers/empty-selection + state (pth/selectable-path-state id content selection) + ;; Contains only the [1 :c1] handler control point + handler-rect (grc/make-rect -1 9 2 2) + state' (ptk/update (path.selection/select-path-area + handler-rect selection false) + state) + ;; Contains both handlers and the top of the curve + mixed-rect (grc/make-rect -1 5 12 7) + state'' (ptk/update (path.selection/select-path-area + mixed-rect selection false) + state)] + (t/is (= {:nodes #{} + :segments #{} + :handlers #{[1 :c1]}} + (get-in state' [:workspace-local :edit-path id :selection]))) + (t/is (= {:nodes #{} + :segments #{1} + :handlers #{}} + (get-in state'' [:workspace-local :edit-path id :selection]))))) + +(t/deftest path-area-selection-ignores-empty-buffer-emissions + (let [id (random-uuid) + content (pth/selectable-path-content) + selection path.helpers/empty-selection + state (pth/selectable-path-state id content selection)] + (t/is (= state + (ptk/update (path.selection/select-path-area + nil selection false) + state))))) + +(t/deftest selected-segments-resolve-to-unique-endpoint-nodes + (let [content (pth/selectable-path-content)] + (t/is (= #{0 1} + (path.helpers/segment-node-indices content #{1}))) + (t/is (= #{0 1 2} + (path.helpers/segment-node-indices content #{1 2}))))) + +(t/deftest moving-selected-segments-translates-endpoints-and-handlers + (let [id (random-uuid) + content (pth/selectable-path-content) + selection {:nodes #{} + :segments #{1} + :handlers #{}} + state (pth/selectable-path-state id content selection) + event (path.edition/move-selected-path-segment + (gpt/point 5 0) + (gpt/point 8 4)) + state' (ptk/update event state) + modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content modifiers)] + (t/is (= (gpt/point 3 4) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 13 4) (path.helpers/node-position content' 1))) + (t/is (= (gpt/point 5 4) (path/get-handler-point content' 1 :c1))) + (t/is (= (gpt/point 11 4) (path/get-handler-point content' 1 :c2))) + (t/is (= (gpt/point 15 4) (path/get-handler-point content' 2 :c1))))) + +(t/deftest moving-a-segment-between-selected-nodes-moves-the-node-selection + ;; A segment between selected nodes moves with the node selection. + (let [id (random-uuid) + content (pth/selectable-path-content) + selection {:nodes #{0 1} :segments #{} :handlers #{}} + state (pth/selectable-path-state id content selection) + event (path.edition/move-selected-path-segment + (gpt/point 5 0) + (gpt/point 8 4)) + state' (ptk/update event state) + modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content modifiers)] + ;; both selected nodes translate by (+3,+4); the unselected node stays put + (t/is (= (gpt/point 3 4) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 13 4) (path.helpers/node-position content' 1))) + (t/is (= (gpt/point 20 0) (path.helpers/node-position content' 2))))) + +(t/deftest moving-selected-opposite-handlers-translates-both + (let [id (random-uuid) + content (pth/selectable-path-content) + selection {:nodes #{} + :segments #{} + :handlers #{[1 :c2] [2 :c1]}} + state (pth/selectable-path-state id content selection) + event (path.edition/modify-selected-handlers + id [1 :c2] {} 3 4 :smart true) + state' (ptk/update event state) + modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content modifiers)] + (t/is (= {:c2x 3 :c2y 4} (get modifiers 1))) + (t/is (= {:c1x 3 :c1y 4} (get modifiers 2))) + (t/is (= (gpt/point 11 4) (path/get-handler-point content' 1 :c2))) + (t/is (= (gpt/point 15 4) (path/get-handler-point content' 2 :c1))))) + +(t/deftest moving-selected-handlers-honours-each-explicit-node-mode + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 2 :c1y 0 :c2x 8 :c2y 0 :x 10 :y 0}} + {:command :curve-to + :params {:c1x 12 :c1y 0 :c2x 18 :c2y 0 :x 20 :y 0}} + {:command :curve-to + :params {:c1x 22 :c1y 0 :c2x 28 :c2y 0 :x 30 :y 0}}]) + selection {:nodes #{} + :segments #{} + :handlers #{[2 :c1] [3 :c1]}} + state (-> (pth/selectable-path-state id content selection) + (assoc-in [:workspace-local :edit-path id :handler-types] + {1 :independent 2 :mirror})) + event (path.edition/modify-selected-handlers + id [2 :c1] {} 3 4 :independent true) + state' (ptk/update event state) + modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content modifiers)] + ;; Selected handlers receive the same drag delta. + (t/is (= (gpt/point 15 4) (path/get-handler-point content' 2 :c1))) + (t/is (= (gpt/point 25 4) (path/get-handler-point content' 3 :c1))) + ;; Each node applies its own mode to the opposite handle. + (t/is (= (gpt/point 8 0) (path/get-handler-point content' 1 :c2))) + (t/is (= (gpt/point 15 -4) (path/get-handler-point content' 2 :c2))))) + +(t/deftest arrow-move-nudges-selected-handlers + ;; Arrow keys nudge selected handlers. + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content + {:nodes #{} :segments #{} :handlers #{[2 :c1]}}) + state' (ptk/update (path.edition/set-move-modifier [] #{[2 :c1]} (gpt/point 0 5)) + state) + mods (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content mods)] + (t/is (= {:c1x 0 :c1y 5} (get mods 2))) + ;; [2 :c1] base (12,0) -> (12,5); node 1 and its opposite handle stay put + (t/is (= (gpt/point 12 5) (path/get-handler-point content' 2 :c1))) + (t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1))) + (t/is (= (gpt/point 8 0) (path/get-handler-point content' 1 :c2))))) + +(t/deftest arrow-move-nudges-selected-segment-endpoints + ;; Arrow keys nudge segment endpoints and their handles. + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content + {:nodes #{} :segments #{1} :handlers #{}}) + node-idx (path.helpers/segment-node-indices content #{1}) + points (path.helpers/node-positions content node-idx) + state' (ptk/update (path.edition/set-move-modifier points #{} (gpt/point 0 5)) + state) + mods (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content mods)] + ;; segment 1 connects node 0 (0,0) and node 1 (10,0); both move by (0,5) + (t/is (= (gpt/point 0 5) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 10 5) (path.helpers/node-position content' 1))) + ;; the endpoint nodes' handles move rigidly with them + (t/is (= (gpt/point 2 5) (path/get-handler-point content' 1 :c1))) + (t/is (= (gpt/point 12 5) (path/get-handler-point content' 2 :c1))))) + +(t/deftest align-nodes-aligns-selected-nodes-to-an-edge + ;; Aligning nodes updates the drawing content. + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 4}} + {:command :line-to :params {:x 4 :y 20}}]) + state (pth/selectable-path-state id content + {:nodes #{0 1 2} :segments #{} :handlers #{}}) + state' (ptk/update (path.tools/align-nodes :hleft) state) + content' (get-in state' [:workspace-drawing :object :content])] + ;; every selected node's x becomes the min x (0), y is untouched + (t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 0 4) (path.helpers/node-position content' 1))) + (t/is (= (gpt/point 0 20) (path.helpers/node-position content' 2))))) + +(t/deftest distribute-nodes-spaces-selected-nodes-evenly + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 3 :y 5}} + {:command :line-to :params {:x 10 :y 9}}]) + state (pth/selectable-path-state id content + {:nodes #{0 1 2} :segments #{} :handlers #{}}) + state' (ptk/update (path.tools/distribute-nodes :horizontal) state) + content' (get-in state' [:workspace-drawing :object :content])] + ;; the middle node is centered on x between the two extremes (0 and 10) + (t/is (= (gpt/point 5 5) (path.helpers/node-position content' 1))) + ;; the extreme nodes stay put + (t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 10 9) (path.helpers/node-position content' 2))))) + +(t/deftest set-selection-coordinate-moves-selected-points + ;; Coordinate edits move selected nodes and handlers. + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}}])] + ;; single node: only that node moves + (let [state (pth/selectable-path-state id content + {:nodes #{1} :segments #{} :handlers #{}}) + state' (ptk/update (path.tools/set-selection-coordinate :y 7) state) + content' (get-in state' [:workspace-drawing :object :content])] + (t/is (= (gpt/point 10 7) (path.helpers/node-position content' 1))) + (t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 20 0) (path.helpers/node-position content' 2)))) + ;; multi node: every selected node's coordinate is set to the value + (let [state (pth/selectable-path-state id content + {:nodes #{0 2} :segments #{} :handlers #{}}) + state' (ptk/update (path.tools/set-selection-coordinate :x 5) state) + content' (get-in state' [:workspace-drawing :object :content])] + (t/is (= (gpt/point 5 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 5 0) (path.helpers/node-position content' 2))) + (t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1))))) + ;; a coincident closed-seam node moves as one logical node + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 0 :y 0}}]) + state (pth/selectable-path-state id content + {:nodes #{0} :segments #{} :handlers #{}}) + state' (ptk/update (path.tools/set-selection-coordinate :y 7) state) + content' (get-in state' [:workspace-drawing :object :content])] + (t/is (= (gpt/point 0 7) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 0 7) (path.helpers/node-position content' 2)))) + ;; a selected handler on an independent node moves only its own control point + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to :params {:c1x 2 :c1y 2 :c2x 8 :c2y 2 :x 10 :y 0}}]) + state (pth/selectable-path-state id content + {:nodes #{} :segments #{} :handlers #{[1 :c1]}}) + state' (ptk/update (path.tools/set-selection-coordinate :x 4) state) + curve (nth (get-in state' [:workspace-drawing :object :content]) 1)] + ;; c1 x set to 4; c1y, c2 and the anchor untouched + (t/is (= 4 (get-in curve [:params :c1x]))) + (t/is (= 2 (get-in curve [:params :c1y]))) + (t/is (= 8 (get-in curve [:params :c2x]))) + (t/is (= 10 (get-in curve [:params :x]))))) + +(t/deftest set-selection-coordinate-mirrors-opposite-handler + ;; Moving a mirrored handler updates its opposite. + (let [id (random-uuid) + ;; node 1 (10,0) has collinear equal handles: c2 of cmd1 at (8,-2) and + ;; c1 of cmd2 at (12,2) — a mirror node by geometry + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to :params {:c1x 2 :c1y 0 :c2x 8 :c2y -2 :x 10 :y 0}} + {:command :curve-to :params {:c1x 12 :c1y 2 :c2x 18 :c2y 0 :x 20 :y 0}}]) + state (pth/selectable-path-state id content + {:nodes #{} :segments #{} :handlers #{[1 :c2]}}) + state' (ptk/update (path.tools/set-selection-coordinate :x 6) state) + content' (get-in state' [:workspace-drawing :object :content])] + ;; dragged handle c2 of cmd1 -> x=6 (keeps y=-2) + (t/is (= (gpt/point 6 -2) (path/get-handler-point content' 1 :c2))) + ;; opposite (c1 of cmd2) mirrors it about the node (10,0): 2*10-6=14, 2*0-(-2)=2 + (t/is (= (gpt/point 14 2) (path/get-handler-point content' 2 :c1))))) + +(t/deftest change-to-draw-mode-starts-a-line-from-the-selected-node + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}}])] + ;; a middle node: opens a new subpath (move-to) at the node and makes it the + ;; pending origin, so the next click draws a line from it + (let [state (pth/selectable-path-state id content + {:nodes #{1} :segments #{} :handlers #{}}) + state' (ptk/update (path.drawing/change-edit-mode :draw) state) + content' (get-in state' [:workspace-drawing :object :content])] + (t/is (= (gpt/point 10 0) + (get-in state' [:workspace-local :edit-path id :last-point]))) + (t/is (= 4 (count content'))) + (t/is (= :move-to (:command (nth content' 3)))) + (t/is (= (gpt/point 10 0) (path.helpers/node-position content' 3)))) + ;; the drawing tip: just becomes the pending origin (extends), no new subpath + (let [state (pth/selectable-path-state id content + {:nodes #{2} :segments #{} :handlers #{}}) + state' (ptk/update (path.drawing/change-edit-mode :draw) state) + content' (get-in state' [:workspace-drawing :object :content])] + (t/is (= (gpt/point 20 0) + (get-in state' [:workspace-local :edit-path id :last-point]))) + (t/is (= 3 (count content')))) + ;; nothing selected: no pending line + (let [state (pth/selectable-path-state id content + {:nodes #{} :segments #{} :handlers #{}}) + state' (ptk/update (path.drawing/change-edit-mode :draw) state)] + (t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point])))))) + +(t/deftest set-selection-coordinate-translates-segments + ;; Coordinate edits translate selected segments by their bounds. + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 10 :y 10}}]) + ;; select segment index 2 (the vertical line from (10,0) to (10,10)); + ;; its surrounding rect top-left x is 10 + state (pth/selectable-path-state id content + {:nodes #{} :segments #{2} :handlers #{}}) + state' (ptk/update (path.tools/set-selection-coordinate :x 30) state) + content' (get-in state' [:workspace-drawing :object :content])] + ;; the segment's endpoint nodes (1 and 2) move +20 in x; node 0 stays + (t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 30 0) (path.helpers/node-position content' 1))) + (t/is (= (gpt/point 30 10) (path.helpers/node-position content' 2)))) + ;; Moving a segment attached to a closed seam keeps both seam commands together. + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 10 :y 10}} + {:command :line-to :params {:x 0 :y 10}} + {:command :line-to :params {:x 0 :y 0}}]) + state (pth/selectable-path-state id content + {:nodes #{} :segments #{4} :handlers #{}}) + state' (ptk/update (path.tools/set-selection-coordinate :x 20) state) + content' (get-in state' [:workspace-drawing :object :content])] + (t/is (= (gpt/point 20 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 20 10) (path.helpers/node-position content' 3))) + (t/is (= (gpt/point 20 0) (path.helpers/node-position content' 4))))) + +(t/deftest set-selection-coordinate-translates-mixed-segment-and-node-selection + ;; Selected segments and nodes translate as one group. + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}} + {:command :line-to :params {:x 30 :y 0}}]) + ;; The combined bounds start at x=0. + state (pth/selectable-path-state id content + {:nodes #{0} :segments #{3} :handlers #{}}) + state' (ptk/update (path.tools/set-selection-coordinate :x 10) state) + content' (get-in state' [:workspace-drawing :object :content])] + (t/is (= (gpt/point 10 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1))) + (t/is (= (gpt/point 30 0) (path.helpers/node-position content' 2))) + (t/is (= (gpt/point 40 0) (path.helpers/node-position content' 3))))) + +(t/deftest set-selection-coordinate-translates-mixed-segment-and-handler-selection + ;; Standalone selected handlers translate with the group. + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 2 :c1y 2 :c2x 8 :c2y 2 :x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}} + {:command :line-to :params {:x 30 :y 0}}]) + ;; The standalone handler makes the bounds start at x=2. + state (pth/selectable-path-state id content + {:nodes #{} + :segments #{3} + :handlers #{[1 :c1]}}) + state' (ptk/update (path.tools/set-selection-coordinate :x 12) state) + content' (get-in state' [:workspace-drawing :object :content])] + (t/is (= (gpt/point 12 2) (path/get-handler-point content' 1 :c1))) + (t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1))) + (t/is (= (gpt/point 30 0) (path.helpers/node-position content' 2))) + (t/is (= (gpt/point 40 0) (path.helpers/node-position content' 3))))) + +(t/deftest flip-nodes-includes-selected-segment-endpoints + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content + {:nodes #{} :segments #{1} :handlers #{}}) + state' (ptk/update (path.tools/flip-nodes :horizontal) state) + content' (get-in state' [:workspace-drawing :object :content])] + ;; segment 1's endpoints (nodes 0 and 1) mirror across their bbox centre (x=5) + (t/is (= (gpt/point 10 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 0 0) (path.helpers/node-position content' 1))) + ;; node 2 is not an endpoint of segment 1, so it stays put + (t/is (= (gpt/point 20 0) (path.helpers/node-position content' 2))))) + +(t/deftest merge-nodes-includes-selected-segment-endpoints + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content + {:nodes #{} :segments #{1} :handlers #{}}) + state' (ptk/update (path.tools/merge-nodes) state) + content' (get-in state' [:workspace-drawing :object :content]) + pts (path/get-points content')] + ;; segment 1's endpoints (0,0) and (10,0) merge to their midpoint (5,0) + (t/is (some #(= (gpt/point 5 0) %) pts)) + (t/is (< (count pts) 3)))) + +(t/deftest delete-selected-opens-segments-else-removes-nodes + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}} + {:command :line-to :params {:x 30 :y 0}}]) + run (fn [selection] + (let [state (pth/selectable-path-state id content selection) + events (atom [])] + (->> (ptk/watch (path.tools/delete-selected) state nil) + (rx/subs! #(swap! events conj %))) + ;; delete-selected emits a single node-tool event; apply it + (ptk/update (first @events) state))) + move-tos (fn [st] (->> (get-in st [:workspace-drawing :object :content]) + vec + (filter #(= :move-to (:command %))) + count)) + nodes (fn [st] (count (path/get-points + (get-in st [:workspace-drawing :object :content]))))] + ;; deleting the middle segment (index 2) opens the path into two subpaths + (t/is (> (move-tos (run {:nodes #{} :segments #{2} :handlers #{}})) 1)) + ;; Deleting a node leaves fewer than four nodes. + (t/is (< (nodes (run {:nodes #{1} :segments #{} :handlers #{}})) 4)) + ;; Mixed node and segment deletion heals the selected node. + (let [mixed (run {:nodes #{1} :segments #{2} :handlers #{}})] + (t/is (< (nodes mixed) 4)) + (t/is (= 1 (move-tos mixed)))))) + +(t/deftest deleting-a-closed-seam-node-heals-its-adjacent-segments + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 10 :y 10}} + {:command :line-to :params {:x 0 :y 10}} + {:command :line-to :params {:x 0 :y 0}}]) + state (pth/selectable-path-state id content + {:nodes #{0} :segments #{} :handlers #{}}) + events (atom []) + _ (->> (ptk/watch (path.tools/delete-selected) state nil) + (rx/subs! #(swap! events conj %))) + state' (ptk/update (first @events) state) + content' (vec (get-in state' [:workspace-drawing :object :content]))] + (t/is (= [:move-to :line-to :line-to :curve-to] + (mapv :command content'))) + (t/is (= (gpt/point 10 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 10 0) (path.helpers/node-position content' 3))))) + +(t/deftest deleting-a-touching-subpath-seam-heals-before-exiting-edition + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 10}} + {:command :line-to :params {:x -10 :y 7}} + {:command :line-to :params {:x -10 :y 3}} + {:command :line-to :params {:x 0 :y 0}} + {:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 3}} + {:command :line-to :params {:x 10 :y 7}} + {:command :line-to :params {:x 0 :y 10}}]) + state (pth/selectable-path-state id content + {:nodes #{0 7} :segments #{} :handlers #{}}) + events (atom []) + _ (->> (ptk/watch (path.tools/delete-selected) state nil) + (rx/subs! #(swap! events conj %))) + state' (ptk/update (first @events) state) + content' (vec (get-in state' [:workspace-drawing :object :content]))] + (t/is (= [:move-to :line-to :line-to :line-to :line-to :curve-to] + (mapv :command content'))) + (t/is (= (gpt/point -10 7) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point -10 7) (path.helpers/node-position content' 5))))) + +(t/deftest delete-selected-with-segments-opens-a-gap-around-the-node + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}} + {:command :line-to :params {:x 30 :y 0}} + {:command :line-to :params {:x 40 :y 0}}]) + state (pth/selectable-path-state id content {:nodes #{2} :segments #{} :handlers #{}}) + events (atom []) + _ (->> (ptk/watch (path.tools/delete-selected-with-segments) state nil) + (rx/subs! #(swap! events conj %))) + state' (ptk/update (first @events) state) + content' (get-in state' [:workspace-drawing :object :content]) + move-tos (->> content' vec (filter #(= :move-to (:command %))) count) + nodes (count (path/get-points content'))] + ;; Removing incident segments opens a gap around the node. + (t/is (= 4 nodes)) + (t/is (= 2 move-tos)))) + +(t/deftest group-handler-drag-ignores-stale-handler-identities + (let [id (random-uuid) + content (path/content + [{:command :move-to + :params {:x 0 :y 0}} + {:command :line-to + :params {:x 10 :y 0}} + {:command :curve-to + :params {:c1x 12 :c1y 0 + :c2x 18 :c2y 0 + :x 20 :y 0}}]) + selection {:nodes #{} + :segments #{} + ;; [1 :c1] points to a line-to and [9 :c2] is out of range + :handlers #{[1 :c1] [2 :c1] [9 :c2]}} + state (pth/selectable-path-state id content selection) + event (path.edition/modify-selected-handlers + id [2 :c1] {} 3 4 :smart true) + state' (ptk/update event state) + modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])] + (t/is (= {:c1x 3 :c1y 4} (get modifiers 2))) + (t/is (nil? (get modifiers 1))) + (t/is (nil? (get modifiers 9))))) + +(t/deftest handler-drag-smart-keeps-a-smooth-node-smooth + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content {:nodes #{} :segments #{} + :handlers #{[2 :c1]}}) + ;; Smart mode keeps the handles aligned. + event (path.edition/modify-selected-handlers id [2 :c1] {} 0 4 :smart true) + state' (ptk/update event state) + modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content modifiers)] + (t/is (= (gpt/point 12 4) (path/get-handler-point content' 2 :c1))) + ;; the opposite rotated to stay collinear -> still a smooth node + (t/is (not= (gpt/point 8 0) (path/get-handler-point content' 1 :c2))) + (t/is (path.helpers/handlers-joined? content' 2 :c1)))) + +(t/deftest handler-drag-independent-breaks-a-smooth-node + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content {:nodes #{} :segments #{} + :handlers #{[2 :c1]}}) + ;; Independent mode leaves the opposite handle in place. + event (path.edition/modify-selected-handlers id [2 :c1] {} 0 4 :independent false) + state' (ptk/update event state) + modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content modifiers)] + (t/is (= (gpt/point 12 4) (path/get-handler-point content' 2 :c1))) + (t/is (= (gpt/point 8 0) (path/get-handler-point content' 1 :c2))) + (t/is (not (path.helpers/handlers-joined? content' 2 :c1))))) + +(t/deftest handler-drag-mirror-rejoins-a-corner-node + (let [id (random-uuid) + content (pth/corner-path-content) + state (pth/selectable-path-state id content {:nodes #{} :segments #{} + :handlers #{[2 :c1]}}) + ;; Mirror mode matches the opposite handle's angle and length. + event (path.edition/modify-selected-handlers id [2 :c1] {} 2 -6 :mirror false) + state' (ptk/update event state) + modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content modifiers)] + (t/is (= (gpt/point 14 0) (path/get-handler-point content' 2 :c1))) + (t/is (= (gpt/point 6 0) (path/get-handler-point content' 1 :c2))) + (t/is (path.helpers/handlers-joined? content' 2 :c1)))) + +(t/deftest handler-drag-aligned-mirrors-angle-keeping-length + (let [id (random-uuid) + content (pth/corner-path-content) + state (pth/selectable-path-state id content {:nodes #{} :segments #{} + :handlers #{}}) + ;; Aligned mode matches the angle and keeps the opposite length. + event (path.edition/modify-selected-handlers id [2 :c1] {} -2 -2 :aligned false) + state' (ptk/update event state) + modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content modifiers)] + (t/is (= (gpt/point 10 4) (path/get-handler-point content' 2 :c1))) + (t/is (= (gpt/point 10 -2) (path/get-handler-point content' 1 :c2))) + (t/is (path.helpers/handlers-joined? content' 2 :c1)))) + +(t/deftest remove-handler-collapses-the-clicked-handler + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content + {:nodes #{} :segments #{} + :handlers #{[1 :c2]}}) + state' (ptk/update (path.tools/remove-handler 1 :c2) state) + content' (get-in state' [:workspace-drawing :object :content])] + ;; the clicked handler rests on its node (10,0); the others are untouched + (t/is (= (gpt/point 10 0) (path/get-handler-point content' 1 :c2))) + (t/is (= (gpt/point 2 0) (path/get-handler-point content' 1 :c1))) + (t/is (= (gpt/point 12 0) (path/get-handler-point content' 2 :c1))))) + +(t/deftest toggle-segment-curve-switches-line-and-curve + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 30 :y 0}}]) + state (pth/selectable-path-state id content path.helpers/empty-selection) + state' (ptk/update (path.tools/toggle-segment-curve 1) state) + content' (get-in state' [:workspace-drawing :object :content]) + state'' (ptk/update (path.tools/toggle-segment-curve 1) state') + content'' (get-in state'' [:workspace-drawing :object :content])] + (t/is (= :curve-to (:command (nth content' 1)))) + ;; handles a third along, offset perpendicular (0.25 * length) into a bow + (t/is (= (gpt/point 10 7.5) (path/get-handler-point content' 1 :c1))) + (t/is (= (gpt/point 20 7.5) (path/get-handler-point content' 1 :c2))) + (t/is (= :line-to (:command (nth content'' 1)))))) + +(t/deftest remove-segment-opens-the-path-keeping-nodes + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}} + {:command :line-to :params {:x 30 :y 0}}]) + state (pth/selectable-path-state id content + {:nodes #{} :segments #{2} :handlers #{}}) + state' (ptk/update (path.tools/remove-segment 2) state) + content' (get-in state' [:workspace-drawing :object :content])] + (t/is (= [[:move-to {:x 0 :y 0}] [:line-to {:x 10 :y 0}] + [:move-to {:x 20 :y 0}] [:line-to {:x 30 :y 0}]] + (mapv (juxt :command :params) content'))) + ;; the removed segment's now-stale selection is pruned + (t/is (= #{} (get-in state' [:workspace-local :edit-path id :selection :segments]))))) + +(t/deftest remove-segment-remaps-handler-types-when-node-indices-shift + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :curve-to + :params {:c1x 12 :c1y 4 :c2x 18 :c2y 4 :x 20 :y 0}} + {:command :curve-to + :params {:c1x 22 :c1y -4 :c2x 28 :c2y -4 :x 30 :y 0}}]) + state (-> (pth/selectable-path-state + id content {:nodes #{2} :segments #{} :handlers #{}}) + (assoc-in [:workspace-local :edit-path id :handler-types] + {2 :aligned})) + state' (ptk/update (path.tools/remove-segment 1) state)] + ;; Remap the selected node after dropping the dangling start. + (t/is (= 3 (count (get-in state' [:workspace-drawing :object :content])))) + (t/is (= #{1} (get-in state' [:workspace-local :edit-path id :selection :nodes]))) + ;; Keep the mode attached to the surviving node. + (t/is (= {1 :aligned} + (get-in state' [:workspace-local :edit-path id :handler-types]))))) + +(t/deftest removing-an-earlier-node-preserves-a-surviving-mirror-mode + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 3 :c1y 0 :c2x 7 :c2y 0 :x 10 :y 0}} + {:command :curve-to + :params {:c1x 13 :c1y 0 :c2x 17 :c2y 0 :x 20 :y 0}} + {:command :curve-to + :params {:c1x 23 :c1y 0 :c2x 28 :c2y 0 :x 30 :y 0}} + {:command :curve-to + :params {:c1x 34 :c1y 0 :c2x 37 :c2y 0 :x 40 :y 0}} + {:command :curve-to + :params {:c1x 43 :c1y 0 :c2x 47 :c2y 0 :x 50 :y 0}}]) + state (-> (pth/selectable-path-state + id content {:nodes #{0} :segments #{} :handlers #{}}) + (assoc-in [:workspace-local :edit-path id :handler-types] + {3 :mirror})) + state' (ptk/update (path.tools/remove-node) state)] + ;; Geometry alone derives the fourth node as aligned. + (t/is (= :aligned (path.helpers/derive-handler-type content 3))) + ;; Remap the explicit mode with the surviving node. + (t/is (= {2 :mirror} + (get-in state' [:workspace-local :edit-path id :handler-types]))))) + +(t/deftest remove-node-with-segments-opens-a-gap + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}} + {:command :line-to :params {:x 30 :y 0}}]) + state (pth/selectable-path-state id content path.helpers/empty-selection) + emitted (atom []) + _ (->> (ptk/watch (path.tools/remove-node-with-segments 1) state nil) + (rx/subs! #(swap! emitted conj %))) + state' (reduce #(ptk/update %2 %1) state @emitted) + content' (get-in state' [:workspace-drawing :object :content])] + ;; node (10,0) and both incident segments are gone; the (0,0) start is + ;; left dangling and dropped too, the rest of the path survives + (t/is (= [[:move-to {:x 20 :y 0}] [:line-to {:x 30 :y 0}]] + (mapv (juxt :command :params) content'))))) + +(t/deftest dragging-a-node-or-segment-onto-another-merges-them + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :move-to :params {:x 12 :y 0}} + {:command :line-to :params {:x 30 :y 0}}]) + mk (fn [selection] + (-> (pth/selectable-path-state id content selection) + (assoc-in [:workspace-local :zoom] 1))) + emit-of (fn [state] + (let [out (atom [])] + (->> (ptk/watch (path.edition/merge-dragged-on-drop) + state (rx/subject)) + (rx/subs! #(swap! out conj %))) + @out)) + welded [{:x 0 :y 0} {:x 11 :y 0} {:x 30 :y 0}]] + (t/testing "a single node dropped within range of another node merges them" + (let [state (mk {:nodes #{1} :segments #{} :handlers #{}}) + events (emit-of state) + content' (vec (get-in (ptk/update (first events) state) + [:workspace-drawing :object :content]))] + (t/is (= 1 (count events))) + ;; Dropped subpath endpoints weld at their midpoint. + (t/is (= welded (mapv :params content'))))) + (t/testing "a dragged segment whose endpoint lands on a node merges too" + ;; segment 1 (nodes (0,0)-(10,0)); its (10,0) end is within range of (12,0) + (let [state (mk {:nodes #{} :segments #{1} :handlers #{}}) + events (emit-of state) + content' (vec (get-in (ptk/update (first events) state) + [:workspace-drawing :object :content]))] + (t/is (= 1 (count events))) + (t/is (= welded (mapv :params content'))))) + (t/testing "a node dropped with no neighbour in range does not merge" + (t/is (empty? (emit-of (mk {:nodes #{3} :segments #{} :handlers #{}}))))))) + +;; Path-local undo and redo events use a seeded local stack. diff --git a/frontend/test/frontend_tests/logic/sidebar_transform_coalescing_test.cljs b/frontend/test/frontend_tests/logic/sidebar_transform_coalescing_test.cljs new file mode 100644 index 0000000000..dacbb4e15f --- /dev/null +++ b/frontend/test/frontend_tests/logic/sidebar_transform_coalescing_test.cljs @@ -0,0 +1,226 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.logic.sidebar-transform-coalescing-test + "Regression tests for the sidebar measures panel transform coalescing + (React error #185): a burst of numeric-input gestures (held arrow key, + wheel, scrub) must collapse to a handful of commits, and the trailing + flush must land the exact final value." + (:require + [app.common.geom.rect :as grc] + [app.common.test-helpers.compositions :as ctho] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.shapes :as cths] + [app.main.data.workspace :as dw] + [app.main.data.workspace.transforms :as-alias dwt] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.pages :as thp] + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw] + [potok.v2.core :as ptk])) + +(t/use-fixtures :each + {:before (fn [] (thp/reset-idmap!) (thw/setup-wasm-mocks!)) + :after (fn [] (thw/teardown-wasm-mocks!))}) + +(def ^:private flush-wait-ms + "How long to keep the store running after a burst so the 50 ms + trailing flush fires before checking the final state." + 150) + +(defn- count-events + "Return an atom counting how many events of `type` get emitted on the + store input stream (i.e. the real commits triggered by the coalescer)." + [store type] + (let [counter (atom 0)] + (->> (ptk/input-stream store) + (rx/filter (ptk/type? type)) + (rx/tap (fn [_] (swap! counter inc))) + (rx/subs! (fn [_] nil))) + counter)) + +(defn- run-store-timed + "Like `ths/run-store`, but emits `:the/end` `wait-ms` after `events` + so the timer-based coalescing (throttle/debounce) gets to fire." + [store done events wait-ms completed-cb] + (->> (ptk/input-stream store) + (rx/filter #(= :the/end %)) + (rx/take 1) + (rx/tap (fn [_] (completed-cb @store))) + (rx/subs! (fn [_] nil) + (fn [cause] + (done) + (t/do-report {:type :error :message "Stream error" :actual cause})) + (fn [_] (done)))) + (doseq [event events] + (ptk/emit! store event)) + (js/setTimeout (fn [] (ptk/emit! store :the/end)) wait-ms)) + +(defn- burst + "A burst of `n` events built with `make-event`, like the stream of + calls a held arrow key or a scrub gesture produces." + [n make-event] + (mapv make-event (range 1 (inc n)))) + +;; --- Positions (update-positions, coalesced in place) ----------------- + +(t/deftest update-positions-burst-commits-exact-final-value-wasm + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-frame :frame1 :x 0 :y 0 :width 100 :height 100)) + store (ths/setup-store file) + frame1 (cths/get-shape file :frame1) + commits (count-events store ::dwt/update-position) + events (burst 20 (fn [i] (dw/update-positions [(:id frame1)] {:x (+ 100 i)})))] + (run-store-timed + store done events flush-wait-ms + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + frame1' (cths/get-shape file' :frame1) + x (-> frame1' :points grc/points->rect :x)] + ;; The trailing flush lands the exact final value... + (t/is (= 120 x)) + ;; ...and the 20-event burst collapsed to a handful of commits. + (t/is (<= @commits 3)))))))) + +(t/deftest update-positions-burst-commits-exact-final-value-svg + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-frame :frame1 :x 0 :y 0 :width 100 :height 100)) + store (ths/setup-store file {:renderer :svg}) + frame1 (cths/get-shape file :frame1) + commits (count-events store ::dwt/update-position) + events (burst 20 (fn [i] (dw/update-positions [(:id frame1)] {:x (+ 100 i)})))] + (run-store-timed + store done events flush-wait-ms + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + frame1' (cths/get-shape file' :frame1) + x (-> frame1' :points grc/points->rect :x)] + (t/is (= 120 x)) + (t/is (<= @commits 3)))))))) + +(t/deftest update-positions-burst-merges-x-and-y + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-frame :frame1 :x 0 :y 0 :width 100 :height 100)) + store (ths/setup-store file) + frame1 (cths/get-shape file :frame1) + commits (count-events store ::dwt/update-position) + events (into (burst 10 (fn [i] (dw/update-positions [(:id frame1)] {:x (+ 100 i)}))) + (burst 10 (fn [i] (dw/update-positions [(:id frame1)] {:y (+ 200 i)}))))] + (run-store-timed + store done events flush-wait-ms + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + frame1' (cths/get-shape file' :frame1) + rect (-> frame1' :points grc/points->rect)] + ;; Partial position maps of the same shape merge, so the last + ;; value of each attribute lands. + (t/is (= 110 (:x rect))) + (t/is (= 210 (:y rect))) + (t/is (<= @commits 3)))))))) + +;; --- Dimensions (update-dimensions-coalesced) -------------------------- + +(t/deftest update-dimensions-burst-commits-exact-final-value-wasm + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 0 :y 0 :width 100 :height 100)) + store (ths/setup-store file) + rect1 (cths/get-shape file :rect1) + commits (count-events store ::dwt/update-dimensions) + events (burst 20 (fn [i] (dw/update-dimensions-coalesced [(:id rect1)] :width (+ 100 i))))] + (run-store-timed + store done events flush-wait-ms + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + rect1' (cths/get-shape file' :rect1) + width (-> rect1' :points grc/points->rect :width)] + (t/is (= 120 width)) + (t/is (<= @commits 3)))))))) + +(t/deftest update-dimensions-burst-commits-exact-final-value-svg + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 0 :y 0 :width 100 :height 100)) + store (ths/setup-store file {:renderer :svg}) + rect1 (cths/get-shape file :rect1) + commits (count-events store ::dwt/update-dimensions) + events (burst 20 (fn [i] (dw/update-dimensions-coalesced [(:id rect1)] :width (+ 100 i))))] + (run-store-timed + store done events flush-wait-ms + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + rect1' (cths/get-shape file' :rect1) + width (-> rect1' :points grc/points->rect :width)] + (t/is (= 120 width)) + (t/is (<= @commits 3)))))))) + +(t/deftest update-dimensions-burst-merges-width-and-height + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 0 :y 0 :width 100 :height 100)) + store (ths/setup-store file) + rect1 (cths/get-shape file :rect1) + commits (count-events store ::dwt/update-dimensions) + events (into (burst 10 (fn [i] (dw/update-dimensions-coalesced [(:id rect1)] :width (+ 100 i)))) + (burst 10 (fn [i] (dw/update-dimensions-coalesced [(:id rect1)] :height (+ 200 i)))))] + (run-store-timed + store done events flush-wait-ms + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + rect1' (cths/get-shape file' :rect1) + rect (-> rect1' :points grc/points->rect)] + ;; Each attribute keeps its own latest queued value. + (t/is (= 110 (:width rect))) + (t/is (= 210 (:height rect))) + ;; At most 3 flushes; the trailing one commits both pending + ;; attributes, hence 4 commit events. + (t/is (<= @commits 4)))))))) + +;; --- Rotation (increase-rotation-coalesced) ---------------------------- + +(t/deftest increase-rotation-burst-commits-exact-final-value-wasm + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 0 :y 0 :width 100 :height 100)) + store (ths/setup-store file) + rect1 (cths/get-shape file :rect1) + commits (count-events store ::dwt/increase-rotation) + events (burst 20 (fn [i] (dw/increase-rotation-coalesced [(:id rect1)] (* i 3))))] + (run-store-timed + store done events flush-wait-ms + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + rect1' (cths/get-shape file' :rect1)] + (t/is (= 60 (:rotation rect1'))) + (t/is (<= @commits 3)))))))) + +(t/deftest increase-rotation-burst-commits-exact-final-value-svg + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 0 :y 0 :width 100 :height 100)) + store (ths/setup-store file {:renderer :svg}) + rect1 (cths/get-shape file :rect1) + commits (count-events store ::dwt/increase-rotation) + events (burst 20 (fn [i] (dw/increase-rotation-coalesced [(:id rect1)] (* i 3))))] + (run-store-timed + store done events flush-wait-ms + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + rect1' (cths/get-shape file' :rect1)] + (t/is (= 60 (:rotation rect1'))) + (t/is (<= @commits 3)))))))) diff --git a/frontend/test/frontend_tests/logic/update_position_test.cljs b/frontend/test/frontend_tests/logic/update_position_test.cljs index 8c55dacd43..119f887aa9 100644 --- a/frontend/test/frontend_tests/logic/update_position_test.cljs +++ b/frontend/test/frontend_tests/logic/update_position_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.update-position-test (:require @@ -12,7 +12,12 @@ [app.common.test-helpers.shapes :as cths] [app.main.data.workspace :as dw] [cljs.test :as t :include-macros true] - [frontend-tests.helpers.state :as ths])) + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw])) + +(t/use-fixtures :each + {:before (fn [] (thw/setup-wasm-mocks!)) + :after (fn [] (thw/teardown-wasm-mocks!))}) (t/deftest test-update-positions-multiple-ids (t/async diff --git a/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs b/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs new file mode 100644 index 0000000000..7e39bf91ee --- /dev/null +++ b/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs @@ -0,0 +1,109 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.logic.wasm-modifiers-nil-id-test + "Reproduces the production crash \"Cannot read properties of null + (reading '__u32_buffer')\". + + A modif-tree containing a nil shape id (production builds elide the + asserts that catch this upstream, e.g. `update-dimensions` called + with `[(:parent-id shape)]` when `shape` is missing) reached + `wasm.api/propagate-modifiers` / `wasm.api/set-structure-modifiers`, + and `mem.h32/write-uuid` crashed calling `uuid/get-u32` on nil while + writing to the WASM heap. + + These tests assert that no nil id ever crosses the WASM boundary and + that valid shapes in the same modif-tree are still processed." + (:require + [app.common.geom.rect :as grc] + [app.common.math :as mth] + [app.common.test-helpers.compositions :as ctho] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.common.types.modifiers :as ctm] + [app.common.uuid :as uuid] + [app.main.data.workspace.modifiers :as dwm] + [app.render-wasm.api :as wasm.api] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw])) + +(def ^:private captured-geometry-entries + "Entries passed to `wasm.api/propagate-modifiers` during a test." + (atom [])) + +(def ^:private captured-structure-entries + "Entries passed to `wasm.api/set-structure-modifiers` during a test." + (atom [])) + +(defn- install-capturing-spies! + "Replace the plain WASM mocks with variants that record their input. + Must run after `thw/setup-wasm-mocks!` so teardown still restores + the real implementations." + [] + (set! wasm.api/propagate-modifiers + (fn [entries _pixel-precision] + (swap! captured-geometry-entries into entries) + (into [] + (map (fn [[id data]] [id (:transform data)])) + entries))) + (set! wasm.api/set-structure-modifiers + (fn [entries] + (swap! captured-structure-entries into entries) + nil))) + +(t/use-fixtures :each + {:before (fn [] + (cthi/reset-idmap!) + (reset! captured-geometry-entries []) + (reset! captured-structure-entries []) + (thw/setup-wasm-mocks!) + (install-capturing-spies!)) + :after (fn [] + (thw/teardown-wasm-mocks!))}) + +(t/deftest nil-id-does-not-reach-propagate-modifiers + ;; A nil-keyed entry must be dropped before the WASM heap write while + ;; the valid entry is still resized. + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 10 :y 20 :width 100 :height 50)) + store (ths/setup-store file) + rect (cths/get-shape file :rect1) + resize (ctm/change-dimensions-modifiers rect :width 200) + modif-tree {nil {:modifiers resize} + (:id rect) {:modifiers resize}} + events [(dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true})]] + (ths/run-store + store done events + (fn [new-state] + (let [entry-ids (into #{} (map first) @captured-geometry-entries) + file' (ths/get-file-from-state new-state) + rect' (cths/get-shape file' :rect1) + width (-> rect' :points grc/points->rect :width)] + (t/is (not (contains? entry-ids nil))) + (t/is (contains? entry-ids (:id rect))) + (t/is (mth/close? 200 width)))))))) + +(t/deftest nil-id-does-not-reach-set-structure-modifiers + ;; A nil-keyed entry with structure modifiers must not produce + ;; structure entries with a nil :parent or :id. + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 10 :y 20 :width 100 :height 50)) + store (ths/setup-store file) + rect (cths/get-shape file :rect1) + modif-tree {nil {:modifiers (ctm/add-children nil [(uuid/next)] 0)} + (:id rect) {:modifiers (ctm/change-dimensions-modifiers rect :width 200)}} + events [(dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true})]] + (ths/run-store + store done events + (fn [_new-state] + (t/is (every? #(some? (:parent %)) @captured-structure-entries)) + (t/is (every? #(some? (:id %)) @captured-structure-entries))))))) diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs index d09024ac5c..b3b53b1941 100644 --- a/frontend/test/frontend_tests/main_errors_test.cljs +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.main-errors-test "Unit tests for app.main.errors. @@ -11,10 +11,18 @@ - stale-asset-error? – pure predicate - exception->error-data – pure transformer - on-error re-entrancy guard – prevents recursive invocations - - flash schedules async emit – ntf/show is not emitted synchronously" + - flash schedules async emit – ntf/show is not emitted synchronously + - organization SSO recovery – expired SSO sessions go back to the provider + - invalid-sso-config handler – requires :organization-id to promote to :sso-error" (:require [app.main.errors :as errors] + [app.main.repo :as rp] + [app.main.router :as rt] + [app.main.store :as st] + [app.util.timers :as tm] + [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock] [potok.v2.core :as ptk])) ;; --------------------------------------------------------------------------- @@ -134,3 +142,257 @@ (errors/on-error (ex-info "test" {:type ::test-reentrant :hint "first"})) ;; The guard must have allowed only the first invocation through. (t/is (= 1 @reentrant-call-count)))) + +;; --------------------------------------------------------------------------- +;; Expired organization SSO session +;; +;; The backend rejects SSO-guarded requests with an :authentication error +;; coded :nitrate-sso-required once the organization SSO session lapses. +;; The user must be sent back through the identity provider instead of +;; being told they have no access to the file. +;; --------------------------------------------------------------------------- + +(def ^:private workspace-href + "https://penpot.example.com/#/workspace?team-id=b8f8bb52-8b70-8144-8004-4a5085f0bdc9") + +(def ^:private organization-id "d1a4c0f2-2f36-8114-8006-1b0e6d9d0c11") + +(defn- sso-required-error + [] + {:type :authentication + :code :nitrate-sso-required + :organization-id organization-id + :team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9"}) + +(t/deftest expired-organization-sso-navigates-to-identity-provider + (t/async done + (t/testing "the browser is sent to the identity provider instead of an error page" + (let [events (atom [])] + (mock/with-mocks + {rp/cmd! (mock/stub + (fn [_command _params] + (rx/of {:authorized false + :redirect-uri "https://idp.example.com/authorize"}))) + rt/get-current-href (constantly workspace-href) + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))} + (fn [done'] + (errors/on-error (sso-required-error)) + (t/is (= [::rt/nav-raw] (mapv ptk/type @events))) + (done')) + done))))) + +(t/deftest expired-organization-sso-comes-back-to-the-current-location + (t/async done + (t/testing "the SSO check asks the provider to return the user where they were" + (let [rpc-calls (atom [])] + (mock/with-mocks + {rp/cmd! (mock/stub + (fn [command params] + (swap! rpc-calls conj {:command command :params params}) + (rx/of {:authorized false + :redirect-uri "https://idp.example.com/authorize"}))) + rt/get-current-href (constantly workspace-href) + st/emit! mock/noop} + (fn [done'] + (errors/on-error (sso-required-error)) + (t/is (= [{:command :check-nitrate-sso + :params {:team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9" + :organization-id organization-id + :url workspace-href}}] + @rpc-calls)) + (done')) + done))))) + +(t/deftest already-satisfied-organization-sso-retries-the-location + (t/async done + (t/testing "a session renewed meanwhile (e.g. in another tab) reloads instead of erroring" + (let [events (atom [])] + (mock/with-mocks + {rp/cmd! (mock/stub + (fn [_command _params] + (rx/of {:authorized true :reason :sso-satisfied}))) + rt/get-current-href (constantly workspace-href) + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))} + (fn [done'] + (errors/on-error (sso-required-error)) + (t/is (= [::rt/reload] (mapv ptk/type @events))) + (done')) + done))))) + +(t/deftest organization-sso-without-usable-provider-shows-the-sso-error-dialog + (t/async done + (t/testing "SSO is required but there is nowhere to go: offer a retry, not a permission error" + (let [assigned* (atom nil)] + (mock/with-mocks + {rp/cmd! (mock/stub + (fn [_command _params] + (rx/of {:authorized false :redirect-uri nil}))) + rt/get-current-href (constantly workspace-href) + rt/assign-exception (fn [error] + (reset! assigned* error) + (ptk/data-event ::assigned error))} + (fn [done'] + (errors/on-error (sso-required-error)) + (t/is (= :sso-error (:type @assigned*))) + (t/is (= organization-id (:organization-id @assigned*))) + (t/is (true? (:is-workspace @assigned*))) + (done')) + done))))) + +(t/deftest organization-sso-without-team-access-reports-a-permission-failure + (t/async done + (t/testing "a user who cannot reach the team keeps getting the authentication error" + (let [assigned* (atom nil)] + (mock/with-mocks + {rp/cmd! (mock/stub + (fn [_command _params] + (rx/of {:authorized true :reason :no-team-access}))) + rt/get-current-href (constantly workspace-href) + rt/assign-exception (fn [error] + (reset! assigned* error) + (ptk/data-event ::assigned error))} + (fn [done'] + (errors/on-error (sso-required-error)) + (t/is (= :authentication (:type @assigned*))) + (t/is (= :nitrate-sso-required (:code @assigned*))) + (done')) + done))))) + +(t/deftest organization-sso-does-not-retry-on-an-unexplained-authorization + (t/async done + (t/testing "reloading on an answer we don't understand would spin on the same rejection" + (let [events (atom [])] + (mock/with-mocks + {rp/cmd! (mock/stub (fn [_command _params] (rx/of {:authorized true}))) + rt/get-current-href (constantly workspace-href) + rt/assign-exception (fn [error] (ptk/data-event ::assigned error)) + st/async-emit! (fn [& emitted] (swap! events into emitted))} + (fn [done'] + (errors/on-error (sso-required-error)) + (t/is (= [::assigned] (mapv ptk/type @events))) + (done')) + done))))) + +(t/deftest organization-sso-error-without-context-is-reported-as-it-arrives + (t/async done + (t/testing "with no organization and no team there is nothing to check" + (let [rpc-calls (atom 0) + assigned* (atom nil)] + (mock/with-mocks + {rp/cmd! (mock/stub (fn [_command _params] + (swap! rpc-calls inc) + (rx/empty))) + rt/get-current-href (constantly workspace-href) + rt/assign-exception (fn [error] + (reset! assigned* error) + (ptk/data-event ::assigned error))} + (fn [done'] + (errors/on-error {:type :authentication + :code :nitrate-sso-required}) + (t/is (zero? @rpc-calls)) + (t/is (= :nitrate-sso-required (:code @assigned*))) + (done')) + done))))) + +(t/deftest a-resultless-organization-sso-check-does-not-wedge-later-rejections + (t/async done + (t/testing "the one-in-flight guard is released even when no answer arrives" + (let [rpc-calls (atom 0)] + (mock/with-mocks + {rp/cmd! (mock/stub (fn [_command _params] + (swap! rpc-calls inc) + (rx/empty))) + rt/get-current-href (constantly workspace-href) + st/emit! mock/noop} + (fn [done'] + (errors/on-error (sso-required-error)) + (errors/on-error (sso-required-error)) + (t/is (= 2 @rpc-calls)) + (done')) + done))))) + +;; A failing check must stay a failing check: the generic handling turns it +;; into a toast, whereas swallowing it would show a permission error for +;; what may be a momentary network blip. The mocked RPC fails on a later +;; tick, like a real request, so the handler is not inside on-error's +;; re-entrancy guard when the failure arrives. + +(def ^:private check-failures (atom [])) + +(defmethod ptk/handle-error ::test-check-failure + [error] + (swap! check-failures conj error)) + +(t/deftest failing-organization-sso-check-is-not-reported-as-missing-access + (t/async done + (reset! check-failures []) + (let [assigned* (atom nil)] + (mock/with-mocks + {rp/cmd! + (mock/stub + (fn [_command _params] + (->> (rx/timer 0) + (rx/mapcat (fn [_] + (rx/throw (ex-info "boom" {:type ::test-check-failure}))))))) + + rt/get-current-href + (constantly workspace-href) + + rt/assign-exception + (fn [error] + (reset! assigned* error) + (ptk/data-event ::assigned error))} + + (fn [done'] + (errors/on-error (sso-required-error)) + (tm/schedule + 50 + (fn [] + (t/is (= [::test-check-failure] (mapv :type @check-failures))) + (t/is (nil? @assigned*)) + (done')))) + done)))) + +;; --------------------------------------------------------------------------- +;; :validation / :invalid-sso-config +;; +;; The SSO error page needs an organization-id to retry meaningfully. Promote +;; to :sso-error only when that id is present; otherwise keep :validation so +;; we do not surface a broken SSO dialog for a future code path that omits it. +;; --------------------------------------------------------------------------- + +(defn- capture-async-exception + "Invoke `ptk/handle-error` while capturing the error map passed to + `rt/assign-exception` via `st/async-emit!`. + + `st/async-emit!` is variadic (`[& params]`); the mock must be too, + otherwise CLJS looks up `IFn$_invoke$arity$variadic` and throws." + [error] + (let [captured (atom nil)] + (with-redefs [st/async-emit! (fn [& events] + (reset! captured (first events))) + rt/assign-exception (fn [err] err)] + (ptk/handle-error error) + @captured))) + +(t/deftest invalid-sso-config-with-organization-id-promotes-to-sso-error + (t/testing "invalid-sso-config with :organization-id is shown as :sso-error" + (let [org-id #uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + assigned (capture-async-exception + {:type :validation + :code :invalid-sso-config + :organization-id org-id + :hint "missing issuer"})] + (t/is (= :sso-error (:type assigned))) + (t/is (= org-id (:organization-id assigned))) + (t/is (= :invalid-sso-config (:code assigned)))))) + +(t/deftest invalid-sso-config-without-organization-id-keeps-validation + (t/testing "invalid-sso-config without :organization-id must not become :sso-error" + (let [assigned (capture-async-exception + {:type :validation + :code :invalid-sso-config + :hint "missing issuer"})] + (t/is (= :validation (:type assigned))) + (t/is (nil? (:organization-id assigned))) + (t/is (= :invalid-sso-config (:code assigned)))))) diff --git a/frontend/test/frontend_tests/plugins/comments_test.cljs b/frontend/test/frontend_tests/plugins/comments_test.cljs index ee19153a73..fa9849e6ac 100644 --- a/frontend/test/frontend_tests/plugins/comments_test.cljs +++ b/frontend/test/frontend_tests/plugins/comments_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.comments-test (:require @@ -17,44 +17,54 @@ (def ^:private plugin-id "00000000-0000-0000-0000-000000000000") (t/deftest comment-thread-remove-allows-the-owner - (let [owner-id (random-uuid) - file-id (random-uuid) - page-id (random-uuid) - thread-id (random-uuid) - emitted (atom nil) - thread (comments/comment-thread-proxy - plugin-id - file-id - page-id - {:id thread-id :owner-id owner-id})] - (set! st/state (atom {:profile {:id owner-id}})) - (with-redefs [r/check-permission (constantly true) - dc/delete-comment-thread-on-workspace - (mock/stub (fn [params callback] - (callback) - [:delete-thread params])) - st/emit! (mock/stub (fn [event] (reset! emitted event)))] - (let [result (.remove thread)] - (t/is (instance? js/Promise result)) - (t/is (= [:delete-thread {:id thread-id}] @emitted)))))) + (t/async done + (let [owner-id (random-uuid) + file-id (random-uuid) + page-id (random-uuid) + thread-id (random-uuid) + emitted (atom nil) + thread (comments/comment-thread-proxy + plugin-id + file-id + page-id + {:id thread-id :owner-id owner-id})] + (set! st/state (atom {:profile {:id owner-id}})) + (mock/with-mocks + {r/check-permission (constantly true) + dc/delete-comment-thread-on-workspace + (mock/stub (fn [params callback] + (callback) + [:delete-thread params])) + st/emit! (mock/stub (fn [event] (reset! emitted event)))} + (fn [done'] + (let [result (.remove thread)] + (t/is (instance? js/Promise result)) + (t/is (= [:delete-thread {:id thread-id}] @emitted)) + (done'))) + done)))) (t/deftest page-remove-comment-thread-emits-delete-event - (let [file-id (random-uuid) - page-id (random-uuid) - thread-id (random-uuid) - emitted (atom nil) - page (page/page-proxy plugin-id file-id page-id) - thread (comments/comment-thread-proxy - plugin-id - file-id - page-id - {:id thread-id :owner-id (random-uuid)})] - (with-redefs [r/check-permission (constantly true) - dc/delete-comment-thread-on-workspace - (mock/stub (fn [params callback] - (callback) - [:delete-thread params])) - st/emit! (mock/stub (fn [event] (reset! emitted event)))] - (let [result (.removeCommentThread page thread)] - (t/is (instance? js/Promise result)) - (t/is (= [:delete-thread {:id thread-id}] @emitted)))))) + (t/async done + (let [file-id (random-uuid) + page-id (random-uuid) + thread-id (random-uuid) + emitted (atom nil) + page (page/page-proxy plugin-id file-id page-id) + thread (comments/comment-thread-proxy + plugin-id + file-id + page-id + {:id thread-id :owner-id (random-uuid)})] + (mock/with-mocks + {r/check-permission (constantly true) + dc/delete-comment-thread-on-workspace + (mock/stub (fn [params callback] + (callback) + [:delete-thread params])) + st/emit! (mock/stub (fn [event] (reset! emitted event)))} + (fn [done'] + (let [result (.removeCommentThread page thread)] + (t/is (instance? js/Promise result)) + (t/is (= [:delete-thread {:id thread-id}] @emitted)) + (done'))) + done)))) diff --git a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs index c417b43dab..a2c86cb346 100644 --- a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs +++ b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.context-shapes-test (:require @@ -11,9 +11,11 @@ [app.common.uuid :as uuid] [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.shapes :as dwsh] + [app.main.data.workspace.texts :as dwtxt] [app.main.data.workspace.wasm-text :as dwwt] [app.main.store :as st] [app.plugins.api :as api] + [app.plugins.reflow :as pwrf] [app.plugins.shape :as shape] [app.util.object :as obj] [beicon.v2.core :as rx] @@ -445,6 +447,24 @@ (set! st/stream (ptk/input-stream test-store)) test-store)) +(t/deftest test-update-shapes-invokes-update-function-once + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1) + {:renderer :svg}) + _ (set! st/state store) + _ (set! st/stream (ptk/input-stream store)) + ^js ctx (api/create-context zero-id) + ^js rect (.createRectangle ctx) + id (obj/get rect "$id") + calls (atom 0)] + (ptk/emit! store + (dwsh/update-shapes + [id] + (fn [shape] + (swap! calls inc) + (assoc shape :opacity 0.5)))) + (t/is (= 1 @calls) "the update function ran once for the committed shape") + (t/is (= 0.5 (.-opacity rect)) "the single computed result was committed"))) + (t/deftest test-wait-for-layout-update-no-pending ;; When nothing is pending the promise resolves immediately via the fast path ;; (the behavior-subject replays the empty map on subscribe). @@ -459,6 +479,209 @@ (t/is false (str "unexpected rejection: " err)) (done))))))) +(t/deftest test-create-text-bridges-dom-measurement + (t/async done + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1) + {:renderer :svg})] + (set! st/state store) + (set! st/stream (ptk/input-stream store)) + (ptk/emit! store (dwtxt/initialize-text-reflow)) + (let [^js ctx (api/create-context zero-id) + ^js text (.createText ctx "Measure me") + id (obj/get text "$id")] + (-> (.waitForLayoutUpdate ctx 20) + (.then #(t/is false "createText resolved before DOM measurement started")) + (.catch #(t/is true "createText stayed bridged to DOM measurement")) + (.then (fn [] + (let [task (wrf/start! :text-measure [id])] + (wrf/finish! task)) + (.waitForLayoutUpdate ctx 100))) + (.then #(t/is true "the bridge drained after measurement started")) + (.catch #(t/is false "the createText bridge did not drain")) + (.then (fn [] + (ptk/emit! store (dwtxt/finalize-text-reflow)) + (done)))))))) + +(t/deftest test-dom-position-data-stays-pending-until-commit + (t/async done + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1) + {:renderer :svg})] + (set! st/state store) + (set! st/stream (ptk/input-stream store)) + (ptk/emit! store (dwtxt/initialize-text-reflow)) + (let [^js ctx (api/create-context zero-id) + ^js text (.createText ctx "Position me") + id (obj/get text "$id") + task (wrf/start! :text-measure [id]) + position-data + [{:x 10 :y 20 :width 30 :height 12}]] + (wrf/finish! task) + (-> (.waitForLayoutUpdate text 100) + (.then + (fn [] + (ptk/emit! store (dwtxt/update-position-data id position-data)) + (-> (.waitForLayoutUpdate text 20) + (.then (constantly false)) + (.catch (constantly true))))) + (.then + (fn [timed-out?] + (t/is timed-out? + "position data stayed pending across its debounce") + (.waitForLayoutUpdate text 500))) + (.then + (fn [] + (let [bounds (.-textBounds text)] + (t/is (= 30 (obj/get bounds "width")) + "the wait exposed the committed text bounds")) + (ptk/emit! store (dwtxt/finalize-text-reflow)) + (done))) + (.catch + (fn [cause] + (ptk/emit! store (dwtxt/finalize-text-reflow)) + (t/is false (str "position-data wait did not settle: " cause)) + (done)))))))) + +(t/deftest test-buffered-text-update-bridges-dom-measurement + (t/async done + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1) + {:renderer :svg})] + (set! st/state store) + (set! st/stream (ptk/input-stream store)) + (ptk/emit! store (dwtxt/initialize-text-reflow)) + (let [^js ctx (api/create-context zero-id) + ^js text (.createText ctx "Before") + id (obj/get text "$id") + task (wrf/start! :text-measure [id])] + (wrf/finish! task) + (-> (.waitForLayoutUpdate text 100) + (.then + (fn [] + (ptk/emit! store (dwsh/update-shapes-buffer-start)) + (set! (.-characters text) "After") + (ptk/emit! store (dwsh/update-shapes-buffer-stop)) + (.waitForLayoutUpdate text 20))) + (.then #(t/is false "buffered update resolved before DOM measurement")) + (.catch #(t/is true "buffered update stayed bridged to DOM measurement")) + (.then + (fn [] + (let [task (wrf/start! :text-measure [id])] + (wrf/finish! task)) + (.waitForLayoutUpdate text 100))) + (.then #(t/is true "the buffered update bridge drained")) + (.catch #(t/is false "the buffered update bridge did not drain")) + (.then + (fn [] + (ptk/emit! store (dwtxt/finalize-text-reflow)) + (done)))))))) + +(t/deftest test-wasm-grow-type-wait-observes-its-resize + (t/async done + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1))] + (set! st/state store) + (set! st/stream (ptk/input-stream store)) + (ptk/emit! store (dwtxt/initialize-text-reflow)) + (let [^js ctx (api/create-context zero-id) + ^js text (.createText ctx "Resize after grow type")] + (-> (.waitForLayoutUpdate text 500) + (.then + (fn [] + (set! (.-growType text) "fixed") + (.waitForLayoutUpdate text 500))) + (.then + (fn [] + (t/is (= "fixed" (.-growType text)) + "the grow-type bridge drained after its WASM resize") + (ptk/emit! store (dwtxt/finalize-text-reflow)) + (done))) + (.catch + (fn [cause] + (ptk/emit! store (dwtxt/finalize-text-reflow)) + (t/is false (str "grow-type wait did not settle: " cause)) + (done)))))))) + +(t/deftest test-cloned-text-bridges-dom-measurement + (t/async done + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1) + {:renderer :svg})] + (set! st/state store) + (set! st/stream (ptk/input-stream store)) + (ptk/emit! store (dwtxt/initialize-text-reflow)) + (let [^js ctx (api/create-context zero-id) + ^js text (.createText ctx "Clone me") + id (obj/get text "$id") + task (wrf/start! :text-measure [id])] + (wrf/finish! task) + (-> (.waitForLayoutUpdate text 100) + (.then + (fn [] + (let [^js clone (.clone text) + clone-id (obj/get clone "$id")] + (-> (.waitForLayoutUpdate clone 20) + (.then #(t/is false "clone resolved before DOM measurement")) + (.catch #(t/is true "clone stayed bridged to DOM measurement")) + (.then + (fn [] + (let [task (wrf/start! :text-measure [clone-id])] + (wrf/finish! task)) + (.waitForLayoutUpdate clone 100))))))) + (.then #(t/is true "the cloned text bridge drained")) + (.catch #(t/is false "the cloned text bridge did not drain")) + (.then + (fn [] + (ptk/emit! store (dwtxt/finalize-text-reflow)) + (done)))))))) + +(t/deftest test-fixed-text-resize-bridges-dom-measurement + (t/async done + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1) + {:renderer :svg})] + (set! st/state store) + (set! st/stream (ptk/input-stream store)) + (ptk/emit! store (dwtxt/initialize-text-reflow)) + (let [^js ctx (api/create-context zero-id) + ^js text (.createText ctx "Resize me") + id (obj/get text "$id") + task (wrf/start! :text-measure [id])] + (wrf/finish! task) + (-> (.waitForLayoutUpdate text 100) + (.then + (fn [] + ;; Finish the grow-type update before resizing. + (set! (.-growType text) "fixed") + (let [task (wrf/start! :text-measure [id])] + (wrf/finish! task)) + (.waitForLayoutUpdate text 100))) + (.then + (fn [] + (.resize text 240 80) + ;; Turn only this short wait into a boolean. + (-> (.waitForLayoutUpdate text 20) + (.then (fn [] false)) + (.catch (fn [_] true))))) + (.then + (fn [timed-out?] + (t/is timed-out? + "fixed text resize stayed bridged to DOM measurement") + (let [task (wrf/start! :text-measure [id])] + (wrf/finish! task)) + (.waitForLayoutUpdate text 100))) + (.then + (fn [] + (t/is true "the resize bridge drained after measurement") + ;; Match the DOM renderer's 0.001 geometry tolerance. + (.resize text 240.0005 80) + (.waitForLayoutUpdate text 100))) + (.then + (fn [] + (t/is true "a sub-tolerance resize opened no DOM bridge") + (ptk/emit! store (dwtxt/finalize-text-reflow)) + (done))) + (.catch + (fn [cause] + (ptk/emit! store (dwtxt/finalize-text-reflow)) + (t/is false (str "unexpected resize bridge rejection: " cause)) + (done)))))))) + (t/deftest test-wait-for-layout-update-pending ;; While a shape is pending the context promise stays unresolved; it resolves ;; once that shape is marked done. @@ -584,6 +807,54 @@ 20)) 20)))))) +(t/deftest test-wait-for-layout-update-ancestor + ;; A shape wait also covers layout on its parents. + (t/async done + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1))] + (set! st/state store) + (set! st/stream (ptk/input-stream store)) + (let [^js ctx (api/create-context zero-id) + ^js board (.createBoard ctx) + ^js rect (.createRectangle ctx)] + (.appendChild board rect) + (let [board-id (obj/get board "$id") + task (wrf/start! :layout [board-id]) + resolved (atom false)] + (-> (.waitForLayoutUpdate rect) + (.then (fn [] (reset! resolved true))) + (.catch (fn [err] + (t/is false (str "unexpected rejection: " err))))) + (js/setTimeout + (fn [] + (t/is (false? @resolved) "child wait must block on a pending ancestor") + (wrf/finish! task) + (js/setTimeout + (fn [] + (t/is (true? @resolved) "resolves once the ancestor drains") + (done)) + 20)) + 20)))))) + +(t/deftest test-shape-wait-observes-file-sync + (t/async done + (let [file (cthf/sample-file :file1 :page-label :page1) + store (ths/setup-store file) + _ (set! st/state store) + _ (set! st/stream (ptk/input-stream store)) + ^js ctx (api/create-context zero-id) + ^js shape (.createRectangle ctx) + task (wrf/start! :sync-file [(:id file)])] + (-> (.waitForLayoutUpdate shape 20) + (.then #(t/is false "shape wait ignored its pending file sync")) + (.catch + (fn [] + (t/is true "shape wait remained pending for its file sync") + (wrf/finish! task) + (.waitForLayoutUpdate shape 100))) + (.then #(t/is true "shape wait drained after the file sync")) + (.catch #(t/is false "shape wait did not drain its file sync")) + (.then (fn [] (done))))))) + (t/deftest test-wait-for-layout-update-invalid-timeout ;; A non-numeric or non-positive timeout is an invalid argument. The method ;; always hands back a promise and rejects it, whatever the plugin's @@ -603,6 +874,7 @@ (rejected? (.waitForLayoutUpdate ctx -5)) (rejected? (.waitForLayoutUpdate ctx js/NaN)) (rejected? (.waitForLayoutUpdate ctx js/Infinity)) + (rejected? (.waitForLayoutUpdate ctx 2147483648)) (rejected? (.waitForLayoutUpdate shape "soon"))]) (.then (fn [results] (t/is (every? true? (array-seq results)) @@ -661,7 +933,7 @@ resolved (atom false)] (ptk/emit! store (dwsh/update-shapes-buffer-start)) (ptk/emit! store (dwwt/resize-wasm-text-all [id])) - (-> (wrf/wait-for-layout-update [id] nil) + (-> (pwrf/wait-for-layout-update [id] nil) (.then (fn [] (reset! resolved true))) (.catch (fn [err] (t/is false (str "unexpected rejection: " err))))) diff --git a/frontend/test/frontend_tests/plugins/file_test.cljs b/frontend/test/frontend_tests/plugins/file_test.cljs index 8d7c6b0d12..e6f2780035 100644 --- a/frontend/test/frontend_tests/plugins/file_test.cljs +++ b/frontend/test/frontend_tests/plugins/file_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.file-test (:require diff --git a/frontend/test/frontend_tests/plugins/format_test.cljs b/frontend/test/frontend_tests/plugins/format_test.cljs index f426941acb..f658c1e64d 100644 --- a/frontend/test/frontend_tests/plugins/format_test.cljs +++ b/frontend/test/frontend_tests/plugins/format_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.format-test (:require diff --git a/frontend/test/frontend_tests/plugins/grid_test.cljs b/frontend/test/frontend_tests/plugins/grid_test.cljs index 035153ccde..dc5c2ff8df 100644 --- a/frontend/test/frontend_tests/plugins/grid_test.cljs +++ b/frontend/test/frontend_tests/plugins/grid_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.grid-test (:require diff --git a/frontend/test/frontend_tests/plugins/interactions_test.cljs b/frontend/test/frontend_tests/plugins/interactions_test.cljs index 20c70fffda..115786e788 100644 --- a/frontend/test/frontend_tests/plugins/interactions_test.cljs +++ b/frontend/test/frontend_tests/plugins/interactions_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.interactions-test (:require diff --git a/frontend/test/frontend_tests/plugins/library_test.cljs b/frontend/test/frontend_tests/plugins/library_test.cljs index 47d5869b1a..e359fd852e 100644 --- a/frontend/test/frontend_tests/plugins/library_test.cljs +++ b/frontend/test/frontend_tests/plugins/library_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.library-test (:require diff --git a/frontend/test/frontend_tests/plugins/local_storage_test.cljs b/frontend/test/frontend_tests/plugins/local_storage_test.cljs index f1f5117ed2..f07aa9355a 100644 --- a/frontend/test/frontend_tests/plugins/local_storage_test.cljs +++ b/frontend/test/frontend_tests/plugins/local_storage_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.local-storage-test (:require diff --git a/frontend/test/frontend_tests/plugins/page_active_validation_test.cljs b/frontend/test/frontend_tests/plugins/page_active_validation_test.cljs index 6f3651e81a..9de486f877 100644 --- a/frontend/test/frontend_tests/plugins/page_active_validation_test.cljs +++ b/frontend/test/frontend_tests/plugins/page_active_validation_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.page-active-validation-test "Tests for the guard that prevents plugins from modifying shapes/properties that diff --git a/frontend/test/frontend_tests/plugins/page_test.cljs b/frontend/test/frontend_tests/plugins/page_test.cljs index d29149e846..d48318b77b 100644 --- a/frontend/test/frontend_tests/plugins/page_test.cljs +++ b/frontend/test/frontend_tests/plugins/page_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.page-test (:require diff --git a/frontend/test/frontend_tests/plugins/parser_test.cljs b/frontend/test/frontend_tests/plugins/parser_test.cljs index e6f78c185f..62f5bb26ed 100644 --- a/frontend/test/frontend_tests/plugins/parser_test.cljs +++ b/frontend/test/frontend_tests/plugins/parser_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.parser-test (:require diff --git a/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs b/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs index c854e4942c..4ce610848c 100644 --- a/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs +++ b/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.shape-bugfixes-test (:require diff --git a/frontend/test/frontend_tests/plugins/text_test.cljs b/frontend/test/frontend_tests/plugins/text_test.cljs index 1b1decdcdb..3f4d96be9d 100644 --- a/frontend/test/frontend_tests/plugins/text_test.cljs +++ b/frontend/test/frontend_tests/plugins/text_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.text-test (:require diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs index de94a08f21..c501b9fb66 100644 --- a/frontend/test/frontend_tests/plugins/tokens_test.cljs +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.tokens-test (:require @@ -385,18 +385,18 @@ theme-id (uuid/next) theme (ctob/make-token-theme :id theme-id :group "mode" :name "Light") emitted (atom []) - invalid (atom [])] - (with-redefs [u/locate-token-set (constantly nil) - u/locate-token-theme (fn [_ id] (when (= id theme-id) theme)) - u/not-valid (fn [_ code value] (swap! invalid conj [code value])) - dwtl/update-token-theme (fn [id theme] {:id id :theme theme}) - st/emit! (fn ([event] (swap! emitted conj event) nil) - ([event & _] (swap! emitted conj event) nil))] + errors (atom [])] + (with-redefs [u/locate-token-set (constantly nil) + u/locate-token-theme (fn [_ id] (when (= id theme-id) theme)) + u/throw-validation-errors? (constantly true) + dwtl/update-token-theme (fn [id theme] {:id id :theme theme}) + st/emit! (fn ([event] (swap! emitted conj event) nil) + ([event & _] (swap! emitted conj event) nil))] (let [theme-proxy (ptok/token-theme-proxy plugin-id file-id theme-id)] ;; Non-id, non-proxy arguments are rejected by the schema coercer. - (.addSet theme-proxy 42) - (.removeSet theme-proxy nil) + (try (.addSet theme-proxy 42) (catch :default e (swap! errors conj e))) + (try (.removeSet theme-proxy nil) (catch :default e (swap! errors conj e))) (t/is (empty? @emitted)) - (t/is (= 2 (count @invalid))) - (t/is (every? #(= :error (first %)) @invalid)))))) + (t/is (= 2 (count @errors))) + (t/is (every? #(instance? js/Error %) @errors)))))) diff --git a/frontend/test/frontend_tests/plugins/utils_test.cljs b/frontend/test/frontend_tests/plugins/utils_test.cljs index 3c731055ea..7238732279 100644 --- a/frontend/test/frontend_tests/plugins/utils_test.cljs +++ b/frontend/test/frontend_tests/plugins/utils_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.utils-test (:require diff --git a/frontend/test/frontend_tests/plugins/value_objects_test.cljs b/frontend/test/frontend_tests/plugins/value_objects_test.cljs index e827e0cfc1..1e88af8ac4 100644 --- a/frontend/test/frontend_tests/plugins/value_objects_test.cljs +++ b/frontend/test/frontend_tests/plugins/value_objects_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL ;; Value-object proxies (shadows, exports, grid tracks) returned by the Plugin ;; API. `format-shadows`, `format-exports` and `format-tracks` hand back live diff --git a/frontend/test/frontend_tests/render_dimensions_test.cljs b/frontend/test/frontend_tests/render_dimensions_test.cljs new file mode 100644 index 0000000000..792b3345b6 --- /dev/null +++ b/frontend/test/frontend_tests/render_dimensions_test.cljs @@ -0,0 +1,78 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.render-dimensions-test + (:require + [app.common.geom.rect :as grc] + [app.common.geom.shapes.bounds :as gsb] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [app.main.render :as render] + [cljs.test :as t :include-macros true])) + +(defn- make-objects + "Create a proper objects map with a root frame and the given shapes." + [& shapes] + (let [root-frame (cts/setup-shape {:id uuid/zero + :type :frame + :parent-id uuid/zero + :frame-id uuid/zero + :name "Root Frame" + :shapes (mapv :id shapes)}) + objects {uuid/zero root-frame}] + (reduce (fn [objs shape] + (assoc objs (:id shape) (assoc shape :frame-id uuid/zero))) + objects + shapes))) + +(t/deftest calculate-dimensions-normal-bounds + (t/testing "Normal bounding box should pass" + (let [shape1 (cts/setup-shape {:type :rect :x 100 :y 100 :width 200 :height 150}) + shape2 (cts/setup-shape {:type :rect :x 400 :y 300 :width 100 :height 100}) + objects (make-objects shape1 shape2) + result (render/calculate-dimensions objects nil)] + (t/is (some? result)) + (t/is (<= (:width result) render/max-export-dimension)) + (t/is (<= (:height result) render/max-export-dimension))))) + +(t/deftest calculate-dimensions-extreme-width + (t/testing "Extreme width should throw export-area-too-large" + (let [shape (cts/setup-shape {:type :rect :x 0 :y 0 :width 200000 :height 100}) + objects (make-objects shape)] + (t/is (thrown-with-msg? + js/Error + #"export area exceeds maximum allowed dimensions" + (render/calculate-dimensions objects nil)))))) + +(t/deftest calculate-dimensions-extreme-height + (t/testing "Extreme height should throw export-area-too-large" + (let [shape (cts/setup-shape {:type :rect :x 0 :y 0 :width 100 :height 200000}) + objects (make-objects shape)] + (t/is (thrown-with-msg? + js/Error + #"export area exceeds maximum allowed dimensions" + (render/calculate-dimensions objects nil)))))) + +(t/deftest calculate-dimensions-extreme-position + (t/testing "Shape at extreme position should throw export-area-too-large" + (let [shape (cts/setup-shape {:type :rect :x 500000 :y 500000 :width 100 :height 100}) + objects (make-objects shape)] + (t/is (thrown-with-msg? + js/Error + #"export area exceeds maximum allowed dimensions" + (render/calculate-dimensions objects nil)))))) + +(t/deftest calculate-dimensions-exactly-at-limit + (t/testing "Bounding box exactly at limit should pass" + (let [shape (cts/setup-shape {:type :rect :x 0 :y 0 :width render/max-export-dimension :height render/max-export-dimension}) + objects (make-objects shape) + result (render/calculate-dimensions objects nil)] + (t/is (some? result)) + (t/is (<= (:width result) render/max-export-dimension)) + (t/is (<= (:height result) render/max-export-dimension))))) diff --git a/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs b/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs index c985b336ae..32ea3a8756 100644 --- a/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.render-wasm.process-objects-test "Unit tests for wasm.api/process-objects. @@ -15,9 +15,9 @@ font URL get no callback (fetch-font returns nil when the URL is already in :fetching) and are permanently stuck with fallback-font layout metrics." (:require + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.wasm :as wasm] [app.render-wasm.api :as wasm.api] - [app.render-wasm.mem :as mem] - [app.render-wasm.wasm :as wasm] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true])) diff --git a/frontend/test/frontend_tests/render_wasm/text_editor_apply_styles_test.cljs b/frontend/test/frontend_tests/render_wasm/text_editor_apply_styles_test.cljs new file mode 100644 index 0000000000..f9681063b0 --- /dev/null +++ b/frontend/test/frontend_tests/render_wasm/text_editor_apply_styles_test.cljs @@ -0,0 +1,102 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.render-wasm.text-editor-apply-styles-test + "Unit tests for applying styles to a selection of text spans. + + `apply-attrs-to-paragraph` splits the affected spans at the selection + boundaries and either merges a map of attrs onto the selected spans or, when + given a function, transforms each selected span. The function form is what + fill operations (add, remove, reorder...) rely on to preserve each span's + existing fills instead of overwriting them." + (:require + [app.render-wasm.text-editor :as text-editor] + [cljs.test :as t :include-macros true])) + +(def ^:private apply-attrs-to-paragraph text-editor/apply-attrs-to-paragraph) + +(defn- span [text fills] + {:text text :fills fills}) + +(def ^:private red {:fill-color "#ff0000" :fill-opacity 1}) +(def ^:private green {:fill-color "#00ff00" :fill-opacity 1}) + +(defn- prepend-fill + "Mirrors the `add-fill` node transform: prepend a fill to the span's fills." + [fill] + (fn [node] (update node :fills #(into [fill] %)))) + +(t/deftest apply-map-attrs + (t/testing "a map of attrs is merged onto the selected span" + (let [para {:children [(span "hello world" [red])]} + result (apply-attrs-to-paragraph para 0 5 {:font-size "20"})] + (t/is (= [(assoc (span "hello" [red]) :font-size "20") + (span " world" [red])] + (:children result)))))) + +(t/deftest apply-fn-preserves-existing-fills + (t/testing "the fn form prepends to the selected span's existing fills" + (let [para {:children [(span "hello world" [red])]} + result (apply-attrs-to-paragraph para 0 5 (prepend-fill green))] + (t/is (= [(span "hello" [green red]) + (span " world" [red])] + (:children result))))) + + (t/testing "each selected span keeps its own fills across multiple spans" + (let [para {:children [(span "foo" [red]) + (span "bar" [green])]} + ;; select the whole paragraph (6 chars) and prepend green + result (apply-attrs-to-paragraph para 0 6 (prepend-fill green))] + (t/is (= [(span "foo" [green red]) + (span "bar" [green green])] + (:children result))))) + + (t/testing "a span outside the selection is left untouched" + (let [para {:children [(span "abcdef" [red])]} + ;; select only "cd" + result (apply-attrs-to-paragraph para 2 4 (prepend-fill green))] + (t/is (= [(span "ab" [red]) + (span "cd" [green red]) + (span "ef" [red])] + (:children result)))))) + +(defn- content [paras] + {:children [{:children paras}]}) + +(defn- para [spans] + {:children spans}) + +(defn- selection [start-para start-offset end-para end-offset] + {:start-para start-para :start-offset start-offset + :end-para end-para :end-offset end-offset}) + +(t/deftest selection-fills + (t/testing "a selection where every span shares the same fills returns that vector" + (let [c (content [(para [(span "hello world" [red])])])] + (t/is (= [red] (text-editor/selection-fills c (selection 0 0 0 5)))))) + + (t/testing "a selection within a single span returns that span's fills" + (let [c (content [(para [(span "abcdef" [red])])])] + (t/is (= [red] (text-editor/selection-fills c (selection 0 2 0 4)))))) + + (t/testing "a selection spanning spans with different fills is :multiple" + (let [c (content [(para [(span "foo" [red]) + (span "bar" [green])])])] + (t/is (= :multiple (text-editor/selection-fills c (selection 0 0 0 6)))))) + + (t/testing "a selection restricted to one uniform span is not :multiple" + (let [c (content [(para [(span "foo" [red]) + (span "bar" [green])])])] + (t/is (= [green] (text-editor/selection-fills c (selection 0 3 0 6)))))) + + (t/testing "a selection across paragraphs with the same fills returns that vector" + (let [c (content [(para [(span "foo" [red])]) + (para [(span "bar" [red])])])] + (t/is (= [red] (text-editor/selection-fills c (selection 0 0 1 3)))))) + + (t/testing "a collapsed selection has no selected spans" + (let [c (content [(para [(span "hello" [red])])])] + (t/is (nil? (text-editor/selection-fills c (selection 0 2 0 2))))))) diff --git a/frontend/test/frontend_tests/render_wasm/text_editor_caret_color_test.cljs b/frontend/test/frontend_tests/render_wasm/text_editor_caret_color_test.cljs index 2594e84995..cac8dc524a 100644 --- a/frontend/test/frontend_tests/render_wasm/text_editor_caret_color_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/text_editor_caret_color_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.render-wasm.text-editor-caret-color-test "Unit tests for the text-editor caret color resolution. @@ -11,7 +11,7 @@ anything else (no fill, gradient, image fills, mixed selection) it falls back to an inverted caret (white painted with a Difference blend)." (:require - [app.render-wasm.serializers.color :as sr-clr] + [app.common.render-wasm.serializers.color :as sr-clr] [app.render-wasm.text-editor :as text-editor] [cljs.test :as t :include-macros true])) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 21c7d23c06..a139201f54 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -11,6 +11,7 @@ [frontend-tests.data.dashboard-test] [frontend-tests.data.exports-assets-test] [frontend-tests.data.nitrate-test] + [frontend-tests.data.profile-test] [frontend-tests.data.repo-test] [frontend-tests.data.store-test] [frontend-tests.data.uploads-test] @@ -21,11 +22,13 @@ [frontend-tests.data.workspace-mcp-test] [frontend-tests.data.workspace-media-test] [frontend-tests.data.workspace-pages-test] + [frontend-tests.data.workspace-path-edition-test] [frontend-tests.data.workspace-reflow-test] [frontend-tests.data.workspace-shortcuts-test] [frontend-tests.data.workspace-texts-test] [frontend-tests.data.workspace-thumbnails-test] [frontend-tests.errors-test] + [frontend-tests.fonts-test] [frontend-tests.helpers-shapes-test] [frontend-tests.logic.comp-remove-swap-slots-test] [frontend-tests.logic.components-and-tokens] @@ -34,6 +37,14 @@ [frontend-tests.logic.groups-test] [frontend-tests.logic.nudge-selected-shapes-test] [frontend-tests.logic.pasting-in-containers-test] + [frontend-tests.logic.path-actions-test] + [frontend-tests.logic.path-clipboard-test] + [frontend-tests.logic.path-helpers-test] + [frontend-tests.logic.path-lifecycle-test] + [frontend-tests.logic.path-tools-test] + [frontend-tests.logic.sidebar-transform-coalescing-test] + [frontend-tests.logic.update-position-test] + [frontend-tests.logic.wasm-modifiers-nil-id-test] [frontend-tests.main-errors-test] [frontend-tests.plugins.comments-test] [frontend-tests.plugins.context-shapes-test] @@ -51,7 +62,9 @@ [frontend-tests.plugins.tokens-test] [frontend-tests.plugins.utils-test] [frontend-tests.plugins.value-objects-test] + [frontend-tests.render-dimensions-test] [frontend-tests.render-wasm.process-objects-test] + [frontend-tests.render-wasm.text-editor-apply-styles-test] [frontend-tests.render-wasm.text-editor-caret-color-test] [frontend-tests.svg-fills-test] [frontend-tests.text-editor-paste-guard-test] @@ -63,6 +76,7 @@ [frontend-tests.tokens.style-dictionary-test] [frontend-tests.tokens.token-errors-test] [frontend-tests.tokens.workspace-tokens-remap-test] + [frontend-tests.ui.check-updates-test] [frontend-tests.ui.colorpicker-token-set-order-test] [frontend-tests.ui.comments-clustering-test] [frontend-tests.ui.comments-position-modifier-test] @@ -70,6 +84,7 @@ [frontend-tests.ui.gradient-handlers-test] [frontend-tests.ui.layout-container-multiple-test] [frontend-tests.ui.measures-menu-props-test] + [frontend-tests.ui.routes-test] [frontend-tests.ui.settings-password-schema-test] [frontend-tests.ui.settings-shortcuts-test] [frontend-tests.util-clipboard-test] @@ -101,6 +116,7 @@ 'frontend-tests.copy-as-svg-test 'frontend-tests.data.dashboard-test 'frontend-tests.data.nitrate-test + 'frontend-tests.data.profile-test 'frontend-tests.data.repo-test 'frontend-tests.data.store-test 'frontend-tests.data.exports-assets-test @@ -112,11 +128,13 @@ 'frontend-tests.data.workspace-mcp-test 'frontend-tests.data.workspace-media-test 'frontend-tests.data.workspace-pages-test + 'frontend-tests.data.workspace-path-edition-test 'frontend-tests.data.workspace-reflow-test 'frontend-tests.data.workspace-shortcuts-test 'frontend-tests.data.workspace-texts-test 'frontend-tests.data.workspace-thumbnails-test 'frontend-tests.errors-test + 'frontend-tests.fonts-test 'frontend-tests.helpers-shapes-test 'frontend-tests.logic.comp-remove-swap-slots-test 'frontend-tests.logic.components-and-tokens @@ -124,8 +142,16 @@ 'frontend-tests.logic.frame-guides-test 'frontend-tests.logic.groups-test 'frontend-tests.logic.nudge-selected-shapes-test + 'frontend-tests.logic.path-actions-test + 'frontend-tests.logic.path-clipboard-test + 'frontend-tests.logic.path-helpers-test + 'frontend-tests.logic.path-lifecycle-test + 'frontend-tests.logic.path-tools-test 'frontend-tests.logic.pasting-in-containers-test 'frontend-tests.main-errors-test + 'frontend-tests.logic.sidebar-transform-coalescing-test + 'frontend-tests.logic.update-position-test + 'frontend-tests.logic.wasm-modifiers-nil-id-test 'frontend-tests.plugins.comments-test 'frontend-tests.plugins.context-shapes-test 'frontend-tests.plugins.file-test @@ -143,6 +169,7 @@ 'frontend-tests.plugins.utils-test 'frontend-tests.plugins.value-objects-test 'frontend-tests.render-wasm.process-objects-test + 'frontend-tests.render-wasm.text-editor-apply-styles-test 'frontend-tests.render-wasm.text-editor-caret-color-test 'frontend-tests.svg-fills-test 'frontend-tests.tokens.copy-paste-props-test @@ -153,6 +180,7 @@ 'frontend-tests.tokens.style-dictionary-test 'frontend-tests.tokens.token-errors-test 'frontend-tests.tokens.workspace-tokens-remap-test + 'frontend-tests.ui.check-updates-test 'frontend-tests.ui.colorpicker-token-set-order-test 'frontend-tests.ui.comments-clustering-test 'frontend-tests.ui.comments-position-modifier-test @@ -160,6 +188,8 @@ 'frontend-tests.ui.gradient-handlers-test 'frontend-tests.ui.layout-container-multiple-test 'frontend-tests.ui.measures-menu-props-test + 'frontend-tests.ui.routes-test + 'frontend-tests.render-dimensions-test 'frontend-tests.text-editor-paste-guard-test 'frontend-tests.ui.settings-password-schema-test 'frontend-tests.ui.settings-shortcuts-test diff --git a/frontend/test/frontend_tests/setup_test.cljs b/frontend/test/frontend_tests/setup_test.cljs index e456791e32..f9b4b37621 100644 --- a/frontend/test/frontend_tests/setup_test.cljs +++ b/frontend/test/frontend_tests/setup_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.setup-test (:require diff --git a/frontend/test/frontend_tests/svg_fills_test.cljs b/frontend/test/frontend_tests/svg_fills_test.cljs index f39adbd49e..1a0d4b66d1 100644 --- a/frontend/test/frontend_tests/svg_fills_test.cljs +++ b/frontend/test/frontend_tests/svg_fills_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.svg-fills-test (:require diff --git a/frontend/test/frontend_tests/svg_filters_test.cljs b/frontend/test/frontend_tests/svg_filters_test.cljs index 2656a00c8d..80fd1b250a 100644 --- a/frontend/test/frontend_tests/svg_filters_test.cljs +++ b/frontend/test/frontend_tests/svg_filters_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.svg-filters-test (:require diff --git a/frontend/test/frontend_tests/text_editor_paste_guard_test.cljs b/frontend/test/frontend_tests/text_editor_paste_guard_test.cljs index 3a7829f450..016352974c 100644 --- a/frontend/test/frontend_tests/text_editor_paste_guard_test.cljs +++ b/frontend/test/frontend_tests/text_editor_paste_guard_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.text-editor-paste-guard-test "Regression tests for the Cannot read properties of undefined diff --git a/frontend/test/frontend_tests/tokens/copy_paste_props_test.cljs b/frontend/test/frontend_tests/tokens/copy_paste_props_test.cljs index 47435af432..86da4c21c0 100644 --- a/frontend/test/frontend_tests/tokens/copy_paste_props_test.cljs +++ b/frontend/test/frontend_tests/tokens/copy_paste_props_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.copy-paste-props-test (:require diff --git a/frontend/test/frontend_tests/tokens/helpers/state.cljs b/frontend/test/frontend_tests/tokens/helpers/state.cljs index ce15579649..5e0f549d94 100644 --- a/frontend/test/frontend_tests/tokens/helpers/state.cljs +++ b/frontend/test/frontend_tests/tokens/helpers/state.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.helpers.state (:require diff --git a/frontend/test/frontend_tests/tokens/helpers/tokens.cljs b/frontend/test/frontend_tests/tokens/helpers/tokens.cljs index 4027be3787..f5a0699c85 100644 --- a/frontend/test/frontend_tests/tokens/helpers/tokens.cljs +++ b/frontend/test/frontend_tests/tokens/helpers/tokens.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.helpers.tokens (:require diff --git a/frontend/test/frontend_tests/tokens/import_export_test.cljs b/frontend/test/frontend_tests/tokens/import_export_test.cljs index e05cefb0bd..982c6a7cae 100644 --- a/frontend/test/frontend_tests/tokens/import_export_test.cljs +++ b/frontend/test/frontend_tests/tokens/import_export_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.import-export-test (:require diff --git a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs index 06e468c4ee..bd711f6b12 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.logic.token-actions-test (:require diff --git a/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs index 24a9ad6ca8..54badf55a2 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.logic.token-data-test (:require diff --git a/frontend/test/frontend_tests/tokens/logic/token_remapping_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_remapping_test.cljs index 18aca80efa..83bec23824 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_remapping_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_remapping_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.logic.token-remapping-test (:require diff --git a/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs b/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs index 220dd1f36b..8e7422e1a3 100644 --- a/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs +++ b/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.style-dictionary-test (:require @@ -122,6 +122,43 @@ (get-in resolved-tokens ["typography.bad" :errors 0 :error/code]))) (done)))))))) +;; Regression: a token with a `nil` value (e.g. a composite typography +;; token saved via the workspace form with no fields filled in) must never +;; reach StyleDictionary — its `tokens-studio` preprocessor assumes a +;; typography token's value is never null and throws an uncaught exception +;; on it, which used to take down resolution for every other token in the +;; file. It should be tagged with an empty-input error instead. +(t/deftest resolve-tokens-nil-value-test + (t/async + done + (let [tokens (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set :id (cthi/new-id! :core-set) + :name "core")) + (ctob/add-token (cthi/id :core-set) + (ctob/make-token {:name "typography.empty" + :value nil + :type :typography})) + (ctob/add-token (cthi/id :core-set) + (ctob/make-token {:name "borderRadius.sm" + :value "12px" + :type :border-radius})) + (ctob/get-all-tokens-map))] + (->> (sd/resolve-tokens tokens) + (rx/subs! + (fn [resolved-tokens] + (t/testing "the nil-value token is tagged with an error instead of crashing" + (t/is (contains? resolved-tokens "typography.empty")) + (t/is (nil? (get-in resolved-tokens ["typography.empty" :resolved-value]))) + (t/is (= :error.token/empty-input + (get-in resolved-tokens ["typography.empty" :errors 0 :error/code])))) + (t/testing "other tokens still resolve normally" + (t/is (= 12 (get-in resolved-tokens ["borderRadius.sm" :resolved-value]))))) + (fn [err] + (t/do-report {:type :error :message "Stream error" :actual err}) + (done)) + (fn [] + (done))))))) + (t/deftest resolve-tokens-interactive-test (t/async done diff --git a/frontend/test/frontend_tests/tokens/token_errors_test.cljs b/frontend/test/frontend_tests/tokens/token_errors_test.cljs index bb8c581dbb..02fcbf3201 100644 --- a/frontend/test/frontend_tests/tokens/token_errors_test.cljs +++ b/frontend/test/frontend_tests/tokens/token_errors_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.token-errors-test (:require diff --git a/frontend/test/frontend_tests/tokens/workspace_tokens_remap_test.cljs b/frontend/test/frontend_tests/tokens/workspace_tokens_remap_test.cljs index cddd8e17d7..679592d6bb 100644 --- a/frontend/test/frontend_tests/tokens/workspace_tokens_remap_test.cljs +++ b/frontend/test/frontend_tests/tokens/workspace_tokens_remap_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.workspace-tokens-remap-test (:require diff --git a/frontend/test/frontend_tests/ui/check_updates_test.cljs b/frontend/test/frontend_tests/ui/check_updates_test.cljs new file mode 100644 index 0000000000..7e51105cae --- /dev/null +++ b/frontend/test/frontend_tests/ui/check_updates_test.cljs @@ -0,0 +1,70 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.ui.check-updates-test + (:require + [app.common.version :as v] + [app.main.ui.dashboard.check-updates :as dcu] + [cljs.test :as t :include-macros true])) + +(def ^:private sample-highlights + (str "# HIGHLIGHTS\n" + "\n" + "## 2.18.0 (Unreleased)\n" + "\n" + "- To do\n" + "\n" + "## 2.17.2\n" + "\n" + "- Background blur is here\n" + "- WebGL rendering gets stronger\n" + "\n" + "## 2.17.1\n" + "\n" + "- MCP connection status and more\n" + "- Design tokens: more visible, more user-friendly\n")) + +(t/deftest parse-latest-released-version-skips-unreleased + (t/is (= "2.17.2" (dcu/parse-latest-released-version sample-highlights)))) + +(t/deftest parse-latest-released-version-first-released + (t/is (= "2.17.2" + (dcu/parse-latest-released-version + "## 2.17.2\n\n- Fix\n\n## 2.17.1\n\n- Fix\n")))) + +(t/deftest parse-latest-released-version-only-unreleased + (t/is (nil? (dcu/parse-latest-released-version + "## 2.18.0 (Unreleased)\n\n- WIP\n")))) + +(t/deftest parse-latest-released-version-empty + (t/is (nil? (dcu/parse-latest-released-version ""))) + (t/is (nil? (dcu/parse-latest-released-version "# HIGHLIGHTS\n")))) + +(t/deftest parse-highlights-skips-unreleased-and-collects-bullets + (t/is (= [{:version "2.17.2" + :items ["Background blur is here" + "WebGL rendering gets stronger"]} + {:version "2.17.1" + :items ["MCP connection status and more" + "Design tokens: more visible, more user-friendly"]}] + (dcu/parse-highlights sample-highlights)))) + +(t/deftest version-compare + (t/is (zero? (v/compare-versions "2.17.1" "2.17.1"))) + (t/is (pos? (v/compare-versions "2.17.2" "2.17.1"))) + (t/is (neg? (v/compare-versions "2.17.1" "2.17.2"))) + (t/is (pos? (v/compare-versions "3.0.0" "2.99.99"))) + (t/is (neg? (v/compare-versions "2.17.2" "2.17.10")))) + +(t/deftest highlights-until-installed + (let [sections (dcu/parse-highlights sample-highlights)] + (t/is (= [{:version "2.17.2" + :items ["Background blur is here" + "WebGL rendering gets stronger"]}] + (dcu/highlights-until-installed sections "2.17.1"))) + (t/is (= [] (dcu/highlights-until-installed sections "2.17.2"))) + (t/is (= sections (dcu/highlights-until-installed sections "2.16.0"))) + (t/is (= [] (dcu/highlights-until-installed sections "2.17.10"))))) diff --git a/frontend/test/frontend_tests/ui/colorpicker_token_set_order_test.cljs b/frontend/test/frontend_tests/ui/colorpicker_token_set_order_test.cljs index 34347ffa26..1c55366c13 100644 --- a/frontend/test/frontend_tests/ui/colorpicker_token_set_order_test.cljs +++ b/frontend/test/frontend_tests/ui/colorpicker_token_set_order_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.ui.colorpicker-token-set-order-test (:require diff --git a/frontend/test/frontend_tests/ui/comments_clustering_test.cljs b/frontend/test/frontend_tests/ui/comments_clustering_test.cljs index eeaa7d1a97..3514915a28 100644 --- a/frontend/test/frontend_tests/ui/comments_clustering_test.cljs +++ b/frontend/test/frontend_tests/ui/comments_clustering_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.ui.comments-clustering-test (:require diff --git a/frontend/test/frontend_tests/ui/comments_position_modifier_test.cljs b/frontend/test/frontend_tests/ui/comments_position_modifier_test.cljs index 3f842df31a..840e1e2ab0 100644 --- a/frontend/test/frontend_tests/ui/comments_position_modifier_test.cljs +++ b/frontend/test/frontend_tests/ui/comments_position_modifier_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.ui.comments-position-modifier-test (:require diff --git a/frontend/test/frontend_tests/ui/ds_controls_numeric_input_test.cljs b/frontend/test/frontend_tests/ui/ds_controls_numeric_input_test.cljs index a4e8ce7ef2..4fe508ce56 100644 --- a/frontend/test/frontend_tests/ui/ds_controls_numeric_input_test.cljs +++ b/frontend/test/frontend_tests/ui/ds_controls_numeric_input_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.ui.ds-controls-numeric-input-test (:require diff --git a/frontend/test/frontend_tests/ui/gradient_handlers_test.cljs b/frontend/test/frontend_tests/ui/gradient_handlers_test.cljs index c20deaca39..4d820ed2b7 100644 --- a/frontend/test/frontend_tests/ui/gradient_handlers_test.cljs +++ b/frontend/test/frontend_tests/ui/gradient_handlers_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.ui.gradient-handlers-test (:require diff --git a/frontend/test/frontend_tests/ui/layout_container_multiple_test.cljs b/frontend/test/frontend_tests/ui/layout_container_multiple_test.cljs index 19c4278057..10b64d95b1 100644 --- a/frontend/test/frontend_tests/ui/layout_container_multiple_test.cljs +++ b/frontend/test/frontend_tests/ui/layout_container_multiple_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.ui.layout-container-multiple-test (:require diff --git a/frontend/test/frontend_tests/ui/measures_menu_props_test.cljs b/frontend/test/frontend_tests/ui/measures_menu_props_test.cljs index 8214572f59..e4a7b81903 100644 --- a/frontend/test/frontend_tests/ui/measures_menu_props_test.cljs +++ b/frontend/test/frontend_tests/ui/measures_menu_props_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.ui.measures-menu-props-test (:require diff --git a/frontend/test/frontend_tests/ui/routes_test.cljs b/frontend/test/frontend_tests/ui/routes_test.cljs new file mode 100644 index 0000000000..e85246d954 --- /dev/null +++ b/frontend/test/frontend_tests/ui/routes_test.cljs @@ -0,0 +1,97 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.ui.routes-test + (:require + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.main.repo :as rp] + [app.main.store :as st] + [app.main.ui.routes :as routes] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock])) + +(defn- workspace-match + [team-id] + {:data {:name :workspace} + :params {:path {}} + :query-params {:team-id (str team-id)}}) + +(t/deftest sso-check-is-cached-for-five-minutes + (t/async done + (let [team-id (uuid/next) + match (workspace-match team-id) + now (atom (ct/inst "2026-08-11T10:00:00Z")) + rpc-calls (atom 0) + events (atom [])] + (mock/with-mocks + {cf/flags (conj cf/flags :admin-console) + ct/now (mock/stub (fn [] @now)) + rp/cmd! (mock/stub + (fn [command params] + (t/is (= :check-nitrate-sso command)) + (t/is (= team-id (:team-id params))) + (swap! rpc-calls inc) + (rx/of {:authorized true}))) + st/emit! (mock/stub + (fn [& emitted] + (swap! events into emitted)))} + (fn [done'] + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + (reset! now (ct/plus @now #js {:minutes 4 :seconds 59})) + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + (t/is (= 1 @rpc-calls)) + (t/is (= 2 (count @events))) + (done')) + done)))) + +(t/deftest sso-check-is-refreshed-after-five-minutes + (t/async done + (let [team-id (uuid/next) + match (workspace-match team-id) + now (atom (ct/inst "2026-08-11T10:00:00Z")) + rpc-calls (atom 0)] + (mock/with-mocks + {cf/flags (conj cf/flags :admin-console) + ct/now (mock/stub (fn [] @now)) + rp/cmd! (mock/stub + (fn [_ _] + (swap! rpc-calls inc) + (rx/of {:authorized true}))) + st/emit! mock/noop} + (fn [done'] + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + (reset! now (ct/plus @now #js {:minutes 5})) + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + (t/is (= 2 @rpc-calls)) + (done')) + done)))) + +(t/deftest sso-redirect-result-is-not-cached + (t/async done + (let [team-id (uuid/next) + match (workspace-match team-id) + rpc-calls (atom 0) + events (atom [])] + (mock/with-mocks + {cf/flags (conj cf/flags :admin-console) + rp/cmd! (mock/stub + (fn [_ _] + (swap! rpc-calls inc) + (rx/of {:authorized false + :redirect-uri "https://idp.example.com/authorize"}))) + st/emit! (mock/stub + (fn [& emitted] + (swap! events into emitted)))} + (fn [done'] + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + (t/is (= 2 @rpc-calls)) + (t/is (= 2 (count @events))) + (done')) + done)))) diff --git a/frontend/test/frontend_tests/ui/settings_password_schema_test.cljs b/frontend/test/frontend_tests/ui/settings_password_schema_test.cljs index ac305b81fb..29d4f05199 100644 --- a/frontend/test/frontend_tests/ui/settings_password_schema_test.cljs +++ b/frontend/test/frontend_tests/ui/settings_password_schema_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.ui.settings-password-schema-test (:require diff --git a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs index 8bbf5af6b2..ba668650d7 100644 --- a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs +++ b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs @@ -5,6 +5,7 @@ [app.main.ui.settings.restore-shortcuts-modal :as restore-modal] [app.main.ui.settings.shortcuts :as sut] [app.main.ui.shortcuts :as ui-shortcuts] + [app.util.strings :refer [matches-search]] [cljs.test :as t :include-macros true] [clojure.string :as str])) @@ -221,3 +222,72 @@ (let [result (restore-modal/extract-shortcut-keys :next-frame {} :viewer)] (t/is (nth result 3) "Should return a default command for :next-frame in :viewer context"))) + +;; --- shortcut->command-string + command-based search -------------------- +;; The search in both the settings shortcuts page and the workspace sidebar +;; matches shortcut entries by their translated name AND by their key-combo +;; string. `shortcut->command-string` (in `app.main.ui.shortcuts`) extracts the +;; searchable form from `:command`/`:show-command`; `matches-search` does the +;; case-insensitive substring match. These tests pin that contract so searching +;; e.g. "ctrl" surfaces every shortcut whose combo includes ctrl. + +(t/deftest shortcut->command-string-extracts-string-command + (t/testing "a plain string command is returned lowercased" + (t/is (= "ctrl+z" (ui-shortcuts/shortcut->command-string + {:command "ctrl+z"}))))) + +(t/deftest shortcut->command-string-joins-vector-command + (t/testing "a vector command (key sequence) is joined with spaces so every + token is individually searchable" + (t/is (= "g v" (ui-shortcuts/shortcut->command-string + {:command ["g" "v"]}))))) + +(t/deftest shortcut->command-string-prefers-show-command + (t/testing ":show-command (display override) wins over :command" + (t/is (= "shift+x" (ui-shortcuts/shortcut->command-string + {:command "ctrl+z" :show-command "shift+x"}))))) + +(t/deftest shortcut->command-string-empty-for-section-node + (t/testing "a node without :command/:show-command (e.g. a section or + subsection heading) yields an empty string so it never matches a + non-blank command search" + (t/is (= "" (ui-shortcuts/shortcut->command-string + {:translation "workspace"}))))) + +(t/deftest shortcut->command-string-lowercases + (t/testing "the result is lowercased so search is case-insensitive" + (t/is (= "ctrl+shift+z" (ui-shortcuts/shortcut->command-string + {:command "Ctrl+Shift+Z"}))))) + +(t/deftest command-search-matches-ctrl-prefix + (t/testing "searching 'ctrl' matches a shortcut whose command contains ctrl" + (let [shortcut {:command "ctrl+shift+s" + :translation "Save all"}] + (t/is (matches-search (ui-shortcuts/shortcut->command-string shortcut) + "ctrl"))))) + +(t/deftest command-search-does-not-match-when-command-lacks-term + (t/testing "searching 'alt' does not match a shortcut with no alt in its combo" + (let [shortcut {:command "ctrl+z" + :translation "Undo"}] + (t/is (not (matches-search (ui-shortcuts/shortcut->command-string shortcut) + "alt")))))) + +(t/deftest command-search-matches-key-sequence-vector + (t/testing "searching a single key in a key-sequence vector command matches" + (let [shortcut {:command ["g" "v"] + :translation "Group"}] + (t/is (matches-search (ui-shortcuts/shortcut->command-string shortcut) + "g"))))) + +(t/deftest search-matches-by-translation-or-command + (t/testing "a search term matches if it appears in either the translation or + the command string — the OR that the filter predicates use" + (let [shortcut {:command "ctrl+s" + :translation "Save"}] + ;; by translation + (t/is (or (matches-search (:translation shortcut) "save") + (matches-search (ui-shortcuts/shortcut->command-string shortcut) "save"))) + ;; by command + (t/is (or (matches-search (:translation shortcut) "ctrl") + (matches-search (ui-shortcuts/shortcut->command-string shortcut) "ctrl")))))) diff --git a/frontend/test/frontend_tests/util/dom/dnd_test.cljs b/frontend/test/frontend_tests/util/dom/dnd_test.cljs index 60c7ad725d..37e45c1fb1 100644 --- a/frontend/test/frontend_tests/util/dom/dnd_test.cljs +++ b/frontend/test/frontend_tests/util/dom/dnd_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.util.dom.dnd-test (:require diff --git a/frontend/test/frontend_tests/util_clipboard_test.cljs b/frontend/test/frontend_tests/util_clipboard_test.cljs index 4c664786a1..aa6b2742cb 100644 --- a/frontend/test/frontend_tests/util_clipboard_test.cljs +++ b/frontend/test/frontend_tests/util_clipboard_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.util-clipboard-test "Regression tests for `to-clipboard-multi` (issue #10596). diff --git a/frontend/test/frontend_tests/util_object_test.cljs b/frontend/test/frontend_tests/util_object_test.cljs index 1238c33e10..68d982fda2 100644 --- a/frontend/test/frontend_tests/util_object_test.cljs +++ b/frontend/test/frontend_tests/util_object_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.util-object-test (:require diff --git a/frontend/test/frontend_tests/util_range_tree_test.cljs b/frontend/test/frontend_tests/util_range_tree_test.cljs index 52e112d15e..c0fdd5f8e0 100644 --- a/frontend/test/frontend_tests/util_range_tree_test.cljs +++ b/frontend/test/frontend_tests/util_range_tree_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.util-range-tree-test (:require diff --git a/frontend/test/frontend_tests/util_simple_math_test.cljs b/frontend/test/frontend_tests/util_simple_math_test.cljs index c06f509d45..827fe7b18b 100644 --- a/frontend/test/frontend_tests/util_simple_math_test.cljs +++ b/frontend/test/frontend_tests/util_simple_math_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.util-simple-math-test (:require diff --git a/frontend/test/frontend_tests/util_text_editor_test.cljs b/frontend/test/frontend_tests/util_text_editor_test.cljs index 0823a77d10..bd38a36e9e 100644 --- a/frontend/test/frontend_tests/util_text_editor_test.cljs +++ b/frontend/test/frontend_tests/util_text_editor_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.util-text-editor-test (:require diff --git a/frontend/test/frontend_tests/util_webapi_test.cljs b/frontend/test/frontend_tests/util_webapi_test.cljs index 1307526ffb..e6ca55d1d6 100644 --- a/frontend/test/frontend_tests/util_webapi_test.cljs +++ b/frontend/test/frontend_tests/util_webapi_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.util-webapi-test (:require diff --git a/frontend/test/frontend_tests/util_zip_test.cljs b/frontend/test/frontend_tests/util_zip_test.cljs index 7cf2d6f609..a9fbc658f7 100644 --- a/frontend/test/frontend_tests/util_zip_test.cljs +++ b/frontend/test/frontend_tests/util_zip_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.util-zip-test (:require diff --git a/frontend/test/frontend_tests/worker_snap_test.cljs b/frontend/test/frontend_tests/worker_snap_test.cljs index 38739a2ae9..c6b1f38a41 100644 --- a/frontend/test/frontend_tests/worker_snap_test.cljs +++ b/frontend/test/frontend_tests/worker_snap_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.worker-snap-test (:require diff --git a/frontend/text-editor/package.json b/frontend/text-editor/package.json index 56aa083345..af4dd7fde3 100644 --- a/frontend/text-editor/package.json +++ b/frontend/text-editor/package.json @@ -19,7 +19,7 @@ "@types/node": "^26.1.2", "@vitest/browser": "^4.1.10", "@vitest/coverage-v8": "^4.1.10", - "@vitest/ui": "^4.1.10", + "@vitest/ui": "^4.1.11", "canvas": "^3.2.3", "esbuild": "^0.28.0", "jsdom": "^30.0.1", @@ -28,5 +28,5 @@ "vite": "^8.2.0", "vitest": "^4.1.10" }, - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/frontend/text-editor/src/editor/Event.js b/frontend/text-editor/src/editor/Event.js index 7df7ba474f..86c4661882 100644 --- a/frontend/text-editor/src/editor/Event.js +++ b/frontend/text-editor/src/editor/Event.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/TextEditor.js b/frontend/text-editor/src/editor/TextEditor.js index e2c320cf43..b02caa76e5 100644 --- a/frontend/text-editor/src/editor/TextEditor.js +++ b/frontend/text-editor/src/editor/TextEditor.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import clipboard from "./clipboard/index.js"; diff --git a/frontend/text-editor/src/editor/clipboard/copy.js b/frontend/text-editor/src/editor/clipboard/copy.js index 9ec2db74d0..6e93c50382 100644 --- a/frontend/text-editor/src/editor/clipboard/copy.js +++ b/frontend/text-editor/src/editor/clipboard/copy.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/clipboard/cut.js b/frontend/text-editor/src/editor/clipboard/cut.js index dd0a1d0d36..55b212d90a 100644 --- a/frontend/text-editor/src/editor/clipboard/cut.js +++ b/frontend/text-editor/src/editor/clipboard/cut.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/clipboard/index.js b/frontend/text-editor/src/editor/clipboard/index.js index 89a8a7d0f5..922ba6b9cd 100644 --- a/frontend/text-editor/src/editor/clipboard/index.js +++ b/frontend/text-editor/src/editor/clipboard/index.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { copy } from "./copy.js"; diff --git a/frontend/text-editor/src/editor/clipboard/paste.js b/frontend/text-editor/src/editor/clipboard/paste.js index ea699349a9..76cc63f919 100644 --- a/frontend/text-editor/src/editor/clipboard/paste.js +++ b/frontend/text-editor/src/editor/clipboard/paste.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { diff --git a/frontend/text-editor/src/editor/clipboard/paste.test.js b/frontend/text-editor/src/editor/clipboard/paste.test.js new file mode 100644 index 0000000000..14dd7a9ac0 --- /dev/null +++ b/frontend/text-editor/src/editor/clipboard/paste.test.js @@ -0,0 +1,67 @@ +import { describe, test, expect } from "vitest"; +import { TextEditorMock } from "../../test/TextEditorMock.js"; +import { SelectionController } from "../controllers/SelectionController.js"; +import { paste } from "./paste.js"; + +/* @vitest-environment jsdom */ + +/** + * Creates a minimal `ClipboardEvent`-like object carrying plain text. + * + * @param {string} text + * @returns {object} + */ +function createPlainTextClipboardEvent(text) { + return { + preventDefault() {}, + clipboardData: { + types: ["text/plain"], + getData(type) { + return type === "text/plain" ? text : ""; + }, + }, + }; +} + +describe("paste", () => { + test("should insert plain text into an empty editor that was just focused", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText(""); + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + textEditorMock.element.focus(); + + paste( + createPlainTextClipboardEvent("Hello, World!"), + textEditorMock, + selectionController, + ); + + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + }); + + test("should insert plain text when the caret is on a paragraph element", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText("Hello, "); + const root = textEditorMock.root; + const paragraph = root.firstChild; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + textEditorMock.element.focus(); + selection.setBaseAndExtent(paragraph, 1, paragraph, 1); + document.dispatchEvent(new Event("selectionchange")); + + paste( + createPlainTextClipboardEvent("World!"), + textEditorMock, + selectionController, + ); + + expect(root.textContent).toBe("Hello, World!"); + }); +}); diff --git a/frontend/text-editor/src/editor/commands/deleteByCut.js b/frontend/text-editor/src/editor/commands/deleteByCut.js index 0c3ff6fffd..fba52207ce 100644 --- a/frontend/text-editor/src/editor/commands/deleteByCut.js +++ b/frontend/text-editor/src/editor/commands/deleteByCut.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/commands/deleteContentBackward.js b/frontend/text-editor/src/editor/commands/deleteContentBackward.js index 3d4ed0a729..036e2696e6 100644 --- a/frontend/text-editor/src/editor/commands/deleteContentBackward.js +++ b/frontend/text-editor/src/editor/commands/deleteContentBackward.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/commands/deleteContentForward.js b/frontend/text-editor/src/editor/commands/deleteContentForward.js index add99a0507..0fa7da5364 100644 --- a/frontend/text-editor/src/editor/commands/deleteContentForward.js +++ b/frontend/text-editor/src/editor/commands/deleteContentForward.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/commands/index.js b/frontend/text-editor/src/editor/commands/index.js index ed04c36a10..df1af72c94 100644 --- a/frontend/text-editor/src/editor/commands/index.js +++ b/frontend/text-editor/src/editor/commands/index.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { insertText } from "./insertText.js"; diff --git a/frontend/text-editor/src/editor/commands/insertParagraph.js b/frontend/text-editor/src/editor/commands/insertParagraph.js index cbd70cb651..6c3246a2ca 100644 --- a/frontend/text-editor/src/editor/commands/insertParagraph.js +++ b/frontend/text-editor/src/editor/commands/insertParagraph.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/commands/insertText.js b/frontend/text-editor/src/editor/commands/insertText.js index 8d1b87f892..6a84fc406d 100644 --- a/frontend/text-editor/src/editor/commands/insertText.js +++ b/frontend/text-editor/src/editor/commands/insertText.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/content/Text.js b/frontend/text-editor/src/editor/content/Text.js index 76fe1f5ae9..0e453c44f8 100644 --- a/frontend/text-editor/src/editor/content/Text.js +++ b/frontend/text-editor/src/editor/content/Text.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/content/dom/Content.js b/frontend/text-editor/src/editor/content/dom/Content.js index 0cb742992d..a16036e8a0 100644 --- a/frontend/text-editor/src/editor/content/dom/Content.js +++ b/frontend/text-editor/src/editor/content/dom/Content.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { createTextSpan, isLikeTextSpan } from "./TextSpan.js"; diff --git a/frontend/text-editor/src/editor/content/dom/Editor.js b/frontend/text-editor/src/editor/content/dom/Editor.js index 9d87ec3414..9339d1e74e 100644 --- a/frontend/text-editor/src/editor/content/dom/Editor.js +++ b/frontend/text-editor/src/editor/content/dom/Editor.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { isElement } from "./Element.js"; diff --git a/frontend/text-editor/src/editor/content/dom/Element.js b/frontend/text-editor/src/editor/content/dom/Element.js index 40c9922684..3b5ecb6f12 100644 --- a/frontend/text-editor/src/editor/content/dom/Element.js +++ b/frontend/text-editor/src/editor/content/dom/Element.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { setStyles } from "./Style.js"; diff --git a/frontend/text-editor/src/editor/content/dom/LineBreak.js b/frontend/text-editor/src/editor/content/dom/LineBreak.js index e2bae6dc6a..c49eeee7f1 100644 --- a/frontend/text-editor/src/editor/content/dom/LineBreak.js +++ b/frontend/text-editor/src/editor/content/dom/LineBreak.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ export const TAG = "BR"; diff --git a/frontend/text-editor/src/editor/content/dom/Paragraph.js b/frontend/text-editor/src/editor/content/dom/Paragraph.js index 465aaea95b..f13c659759 100644 --- a/frontend/text-editor/src/editor/content/dom/Paragraph.js +++ b/frontend/text-editor/src/editor/content/dom/Paragraph.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { diff --git a/frontend/text-editor/src/editor/content/dom/Root.js b/frontend/text-editor/src/editor/content/dom/Root.js index f2dc401c90..1db5dac701 100644 --- a/frontend/text-editor/src/editor/content/dom/Root.js +++ b/frontend/text-editor/src/editor/content/dom/Root.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { createRandomId, createElement, isElement } from "./Element.js"; diff --git a/frontend/text-editor/src/editor/content/dom/Style.js b/frontend/text-editor/src/editor/content/dom/Style.js index bfadad1a6e..ad30de5c0e 100644 --- a/frontend/text-editor/src/editor/content/dom/Style.js +++ b/frontend/text-editor/src/editor/content/dom/Style.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import StyleDeclaration from "../../controllers/StyleDeclaration.js"; diff --git a/frontend/text-editor/src/editor/content/dom/TextNode.js b/frontend/text-editor/src/editor/content/dom/TextNode.js index 25d484d6f4..300d8496db 100644 --- a/frontend/text-editor/src/editor/content/dom/TextNode.js +++ b/frontend/text-editor/src/editor/content/dom/TextNode.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { isTextSpan } from "./TextSpan.js"; @@ -62,3 +62,56 @@ export function getClosestTextNode(node) { if (isEditor(node)) return node.firstChild.firstChild.firstChild.firstChild; throw new Error("Cannot find a text node"); } + +/** + * @typedef {Object} TextNodePosition + * @property {Text|HTMLBRElement} node + * @property {number} offset + */ + +/** + * Resolves a (node, offset) pair to an equivalent position on a text node + * or a line break. + * + * Browsers are free to report a caret on a container element, in which case + * the offset is a child index instead of a character index (Firefox does this + * routinely, e.g. on empty paragraphs). This function walks down the content + * tree to the addressed descendant so callers can always work with text + * nodes. + * + * Unlike `getClosestTextNode`, this never throws: it returns `null` when the + * position cannot be resolved, letting the caller decide the fallback. + * + * @param {Node} node + * @param {number} [offset=0] + * @returns {TextNodePosition|null} + */ +export function resolveTextNodePosition(node, offset = 0) { + if (!node) return null; + if (node.nodeType === Node.TEXT_NODE || isLineBreak(node)) { + return { node, offset }; + } + + if (isTextSpan(node)) { + // Within a text span the children are text nodes or a line break, so an + // index past the last child means "at the end of the last child". + const child = node.childNodes[offset]; + if (child) return resolveTextNodePosition(child, 0); + const lastChild = node.lastChild; + if (!lastChild) return null; + if (lastChild.nodeType !== Node.TEXT_NODE && !isLineBreak(lastChild)) { + return null; + } + return resolveTextNodePosition(lastChild, getTextNodeLength(lastChild)); + } + + if (isParagraph(node) || isRoot(node) || isEditor(node)) { + const child = node.children[offset]; + if (child) return resolveTextNodePosition(child, 0); + const lastChild = node.lastElementChild; + if (!lastChild) return null; + return resolveTextNodePosition(lastChild, lastChild.childNodes.length); + } + + return null; +} diff --git a/frontend/text-editor/src/editor/content/dom/TextNode.test.js b/frontend/text-editor/src/editor/content/dom/TextNode.test.js index fc44374b85..166da09d07 100644 --- a/frontend/text-editor/src/editor/content/dom/TextNode.test.js +++ b/frontend/text-editor/src/editor/content/dom/TextNode.test.js @@ -1,6 +1,13 @@ import { describe, test, expect } from "vitest"; -import { isTextNode, getTextNodeLength } from "./TextNode.js"; +import { + isTextNode, + getTextNodeLength, + resolveTextNodePosition, +} from "./TextNode.js"; import { createLineBreak } from "./LineBreak.js"; +import { createTextSpan, createEmptyTextSpan } from "./TextSpan.js"; +import { createParagraph } from "./Paragraph.js"; +import { createRoot } from "./Root.js"; /* @vitest-environment jsdom */ describe("TextNode", () => { @@ -25,4 +32,104 @@ describe("TextNode", () => { expect(() => getTextNodeLength(null)).toThrowError("Invalid text node"); expect(() => getTextNodeLength(0)).toThrowError("Invalid text node"); }); + + describe("resolveTextNodePosition", () => { + test("should return the same position when the node is already a text node", () => { + const textNode = new Text("Hello, World!"); + expect(resolveTextNodePosition(textNode, 5)).toStrictEqual({ + node: textNode, + offset: 5, + }); + }); + + test("should return the same position when the node is a line break", () => { + const lineBreak = createLineBreak(); + expect(resolveTextNodePosition(lineBreak, 0)).toStrictEqual({ + node: lineBreak, + offset: 0, + }); + }); + + test("should resolve a text span to its child at the given index", () => { + const textNode = new Text("Hello"); + const textSpan = createTextSpan(textNode); + expect(resolveTextNodePosition(textSpan, 0)).toStrictEqual({ + node: textNode, + offset: 0, + }); + }); + + test("should resolve a text span index past the last child to the end of its text", () => { + const textNode = new Text("Hello"); + const textSpan = createTextSpan(textNode); + expect(resolveTextNodePosition(textSpan, 1)).toStrictEqual({ + node: textNode, + offset: 5, + }); + }); + + test("should resolve a paragraph to the text node of the indexed text span", () => { + const first = new Text("Hello, "); + const second = new Text("World!"); + const paragraph = createParagraph([ + createTextSpan(first), + createTextSpan(second), + ]); + expect(resolveTextNodePosition(paragraph, 0)).toStrictEqual({ + node: first, + offset: 0, + }); + expect(resolveTextNodePosition(paragraph, 1)).toStrictEqual({ + node: second, + offset: 0, + }); + expect(resolveTextNodePosition(paragraph, 2)).toStrictEqual({ + node: second, + offset: 6, + }); + }); + + test("should resolve an empty paragraph to its line break", () => { + const textSpan = createEmptyTextSpan(); + const paragraph = createParagraph([textSpan]); + expect(resolveTextNodePosition(paragraph, 0)).toStrictEqual({ + node: textSpan.firstChild, + offset: 0, + }); + }); + + test("should resolve a root to the text node of the indexed paragraph", () => { + const first = new Text("Hello, "); + const second = new Text("World!"); + const root = createRoot([ + createParagraph([createTextSpan(first)]), + createParagraph([createTextSpan(second)]), + ]); + expect(resolveTextNodePosition(root, 1)).toStrictEqual({ + node: second, + offset: 0, + }); + }); + + test("should resolve an editor element to the first text node of its root", () => { + const textNode = new Text("Hello"); + const root = createRoot([createParagraph([createTextSpan(textNode)])]); + const editor = document.createElement("div"); + editor.dataset.itype = "editor"; + editor.appendChild(root); + expect(resolveTextNodePosition(editor, 0)).toStrictEqual({ + node: textNode, + offset: 0, + }); + }); + + test("should return null instead of throwing when the position cannot be resolved", () => { + expect(resolveTextNodePosition(null, 0)).toBe(null); + expect(resolveTextNodePosition(undefined, 0)).toBe(null); + expect(resolveTextNodePosition(document.createElement("div"), 0)).toBe( + null, + ); + expect(resolveTextNodePosition(createParagraph([]), 0)).toBe(null); + }); + }); }); diff --git a/frontend/text-editor/src/editor/content/dom/TextNodeIterator.js b/frontend/text-editor/src/editor/content/dom/TextNodeIterator.js index 62ae2476cb..e10925d1a7 100644 --- a/frontend/text-editor/src/editor/content/dom/TextNodeIterator.js +++ b/frontend/text-editor/src/editor/content/dom/TextNodeIterator.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { SafeGuard } from "../../controllers/SafeGuard.js"; diff --git a/frontend/text-editor/src/editor/content/dom/TextSpan.js b/frontend/text-editor/src/editor/content/dom/TextSpan.js index 1c3a25cd25..cf24f61f6b 100644 --- a/frontend/text-editor/src/editor/content/dom/TextSpan.js +++ b/frontend/text-editor/src/editor/content/dom/TextSpan.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { diff --git a/frontend/text-editor/src/editor/controllers/ChangeController.js b/frontend/text-editor/src/editor/controllers/ChangeController.js index babfcaf97e..73ddd3fd9e 100644 --- a/frontend/text-editor/src/editor/controllers/ChangeController.js +++ b/frontend/text-editor/src/editor/controllers/ChangeController.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index 371d94e99f..494a500e80 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { createLineBreak, isLineBreak } from "../content/dom/LineBreak.js"; @@ -46,6 +46,7 @@ import { getTextNodeLength, getClosestTextNode, isTextNode, + resolveTextNodePosition, } from "../content/dom/TextNode.js"; import TextNodeIterator from "../content/dom/TextNodeIterator.js"; import TextEditor from "../TextEditor.js"; @@ -537,6 +538,14 @@ export class SelectionController extends EventTarget { */ selectAll() { if (this.#textEditor.isEmpty) { + // There is nothing to select, but we still need a valid caret: leaving + // the selection untouched keeps `focusNode` null and makes any later + // insertion (typing, pasting) fail. + const lineBreak = + this.#textEditor.root?.firstElementChild?.firstElementChild?.firstChild; + if (lineBreak) { + this.collapse(lineBreak, 0); + } return this; } @@ -1132,6 +1141,10 @@ export class SelectionController extends EventTarget { * @param {DocumentFragment} fragment */ insertPaste(fragment) { + if (this.isCollapsed && !this.#normalizeFocus()) { + return; + } + const hasOnlyOneParagraph = fragment.children.length === 1; const forceTextSpan = fragment.firstElementChild?.dataset?.textSpan === "force"; @@ -1395,6 +1408,33 @@ export class SelectionController extends EventTarget { return this.collapse(this.focusNode, this.focusOffset + newText.length); } + /** + * Moves the caret to an equivalent position on a text node or a line break. + * + * The browser can report the caret on a container element (with the offset + * being a child index) or, when the editor was focused without any content, + * on nothing at all. Both states break every insertion path, which expects + * the focus node to be a text node or a <br>. + * + * @returns {boolean} true when the focus is usable. + */ + #normalizeFocus() { + if (this.isTextFocus || this.isLineBreakFocus) { + return true; + } + + const position = + resolveTextNodePosition(this.focusNode, this.focusOffset) ?? + resolveTextNodePosition(this.#textEditor.root, 0); + + if (!position?.node?.isConnected) { + return false; + } + + this.collapse(position.node, position.offset); + return true; + } + /** * Replaces the currently focus element * with some text. @@ -1402,6 +1442,10 @@ export class SelectionController extends EventTarget { * @param {string} newText */ insertIntoFocus(newText) { + if (!this.#normalizeFocus()) { + return; + } + if (this.isTextFocus) { this.focusNode.nodeValue = insertInto( this.focusNode.nodeValue, diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.test.js b/frontend/text-editor/src/editor/controllers/SelectionController.test.js index 533e4c751c..662119ed29 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.test.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.test.js @@ -1706,6 +1706,96 @@ describe("SelectionController", () => { ); }); + test("`selectAll` should collapse the caret on the line break when the editor is empty", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText(""); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + textEditorMock.element.focus(); + selectionController.selectAll(); + expect(selectionController.focusNode).toBe( + root.firstChild.firstChild.firstChild, + ); + expect(selectionController.isCollapsed).toBe(true); + }); + + test("`insertIntoFocus` should insert text when the focus node is a paragraph", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText("Hello, "); + const root = textEditorMock.root; + const paragraph = root.firstChild; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus(selection, textEditorMock, paragraph, 1); + selectionController.insertIntoFocus("World!"); + expect(root.textContent).toBe("Hello, World!"); + }); + + test("`insertIntoFocus` should insert text when the focus node is the root", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText("Hello, "); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus(selection, textEditorMock, root, 1); + selectionController.insertIntoFocus("World!"); + expect(root.textContent).toBe("Hello, World!"); + }); + + test("`insertIntoFocus` should insert text when the focus node is the editor element", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText("Hello, "); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus(selection, textEditorMock, textEditorMock.element, 0); + selectionController.insertIntoFocus("World!"); + expect(root.textContent).toBe("World!Hello, "); + }); + + test("`insertIntoFocus` should insert text when there is no known focus node", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText(""); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + textEditorMock.element.focus(); + expect(selectionController.focusNode).toBe(null); + selectionController.insertIntoFocus("Hello, World!"); + expect(root.textContent).toBe("Hello, World!"); + }); + + test("`insertPaste` should insert a fragment when the focus node is a paragraph", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText(", World!"); + const root = textEditorMock.root; + const paragraph = root.firstChild; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus(selection, textEditorMock, paragraph, 0); + const fragment = document.createDocumentFragment(); + fragment.append(createParagraphWith(["Hello"])); + selectionController.insertPaste(fragment); + expect(root.textContent).toBe("Hello, World!"); + }); + test("`cursorToEnd` should move cursor to the end", () => { const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ createParagraphWith(["Hello, "], { diff --git a/frontend/text-editor/src/editor/debug/SelectionControllerDebug.js b/frontend/text-editor/src/editor/debug/SelectionControllerDebug.js index 4c4a0db691..7a9efaf76c 100644 --- a/frontend/text-editor/src/editor/debug/SelectionControllerDebug.js +++ b/frontend/text-editor/src/editor/debug/SelectionControllerDebug.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/layout/LayoutType.js b/frontend/text-editor/src/editor/layout/LayoutType.js index 03515bdc79..aca12249f7 100644 --- a/frontend/text-editor/src/editor/layout/LayoutType.js +++ b/frontend/text-editor/src/editor/layout/LayoutType.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/test/TextEditorMock.js b/frontend/text-editor/src/test/TextEditorMock.js index 0e20d209e7..457d184eb1 100644 --- a/frontend/text-editor/src/test/TextEditorMock.js +++ b/frontend/text-editor/src/test/TextEditorMock.js @@ -4,7 +4,10 @@ import { createEmptyTextSpan, createTextSpan, } from "../editor/content/dom/TextSpan.js"; -import { createLineBreak } from "../editor/content/dom/LineBreak.js"; +import { + createLineBreak, + isLineBreak, +} from "../editor/content/dom/LineBreak.js"; export class TextEditorMock extends EventTarget { /** @@ -135,6 +138,7 @@ export class TextEditorMock extends EventTarget { this.#element = element; this.#root = options?.root; this.#selectionImposterElement = options?.selectionImposterElement; + this.#element.dataset.itype = "editor"; this.#element.appendChild(options?.root); } @@ -145,6 +149,14 @@ export class TextEditorMock extends EventTarget { get root() { return this.#root; } + + get isEmpty() { + return ( + this.#root.children.length === 1 && + this.#root.firstElementChild.children.length === 1 && + isLineBreak(this.#root.firstElementChild.firstElementChild.firstChild) + ); + } } export default TextEditorMock; diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 641b3e014d..128f102aa1 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -388,6 +388,9 @@ msgstr "" msgid "dashboard.change-organization-modal.title" msgstr "Change team's organization" +msgid "dashboard.change-organization-modal.description" +msgstr "Projects and files will remain available to team members. The team will get the configuration from the new organization." + #: src/app/main/ui/dashboard/deleted.cljs:316 msgid "dashboard.clear-trash-button" msgstr "Clear trash" @@ -410,7 +413,7 @@ msgstr "Pin this version" #: src/app/main/ui/components/context_menu_a11y.cljs:300, src/app/main/ui/dashboard/sidebar.cljs:882 msgid "dashboard.default-team-name" -msgstr "Your Penpot" +msgstr "Personal Projects" #: src/app/main/ui/dashboard/deleted.cljs:265 msgid "dashboard.delete-all-forever-confirmation.description" @@ -453,10 +456,14 @@ msgstr "Delete team" msgid "dashboard.deleted.empty-state-description" msgstr "Your trash is empty. Deleted files and projects will appear here." -#: src/app/main/ui/dashboard/grid.cljs:247 +#: src/app/main/ui/dashboard/grid.cljs:252 msgid "dashboard.deleted.will-be-deleted-at" msgstr "Will be deleted %s" +#: src/app/main/ui/dashboard/grid.cljs:256 +msgid "dashboard.grid.last-modified-at" +msgstr "Last modified %s" + #: src/app/main/ui/dashboard/file_menu.cljs:329, src/app/main/ui/workspace/main_menu.cljs:732 msgid "dashboard.download-binary-file" msgstr "Download Penpot file (.penpot)" @@ -757,6 +764,35 @@ msgstr[1] "%s files have been imported successfully." msgid "dashboard.import.import-warning" msgstr "Some files containted invalid objects that have been removed." +msgid "dashboard.import.auto-linked-libraries" +msgid_plural "dashboard.import.auto-linked-libraries" +msgstr[0] "1 library was automatically linked by name." +msgstr[1] "%s libraries were automatically linked by name." + +msgid "dashboard.import.resolve-libraries" +msgstr "Some libraries couldn't be linked automatically. Select the correct library for each:" + +msgid "dashboard.import.resolve-libraries-summary" +msgstr "Review the library links before confirming:" + +msgid "dashboard.import.confirm-library-links" +msgstr "Confirm library links" + +msgid "dashboard.import.review-links" +msgstr "Review links" + +msgid "dashboard.import.summary.auto-linked" +msgstr "Auto-linked" + +msgid "dashboard.import.summary.your-selection" +msgstr "Your selection" + +msgid "dashboard.import.summary.linked" +msgstr "Linked" + +msgid "dashboard.import.summary.no-selection" +msgstr "No library selected" + #: src/app/main/ui/dashboard.cljs:260 msgid "dashboard.import.no-perms" msgstr "You don’t have permission to import to this team" @@ -868,8 +904,8 @@ msgid "dashboard.move-to-other-team" msgstr "Move to other team" #: src/app/main/ui/dashboard/sidebar.cljs:348, src/app/main/ui/dashboard/sidebar.cljs:349, src/app/main/ui/dashboard/sidebar.cljs:761 -msgid "dashboard.my-teams" -msgstr "My Teams" +msgid "dashboard.other-teams" +msgstr "Other teams" #: src/app/main/ui/dashboard/files.cljs:106, src/app/main/ui/dashboard/projects.cljs:253, src/app/main/ui/dashboard/projects.cljs:254 msgid "dashboard.new-file" @@ -1107,11 +1143,11 @@ msgstr "" msgid "dashboard.select-organization-modal.external-invitations-will-be-canceled" msgstr "Pending invitations to external users will be canceled." -#, unused msgid "dashboard.select-organization-modal.permission-info" -msgstr "" -"Here you find all your organizations where you are allowed to create or add " -"teams." +msgstr "Here you'll find the organizations you are part of where you are allowed to move the team." + +msgid "dashboard.select-organization-modal.permission-info-add" +msgstr "Here you'll find the organizations you are part of where you are allowed to create or add teams." #, unused msgid "dashboard.select-organization-modal.select" @@ -1243,7 +1279,7 @@ msgstr "Your project has been moved successfully" #, unused msgid "dashboard.team-belong-organization" -msgstr "This team now belongs to %s" +msgstr "This team is now part of the organization %s" #: src/app/main/ui/dashboard/team.cljs:1602 msgid "dashboard.team-info" @@ -1255,7 +1291,7 @@ msgstr "Team members" #, unused msgid "dashboard.team-no-longer-belong-organization" -msgstr "This team no longer belongs to the organization %s" +msgstr "This team is no longer part of the organization %s" #: src/app/main/ui/dashboard/team.cljs:1609 msgid "dashboard.team-organization" @@ -1430,11 +1466,8 @@ msgid "dashboard.your-name" msgstr "Your name" #: src/app/main/ui/dashboard/file_menu.cljs:40, src/app/main/ui/dashboard/fonts.cljs:46, src/app/main/ui/dashboard/libraries.cljs:55, src/app/main/ui/dashboard/projects.cljs:352, src/app/main/ui/dashboard/search.cljs:47, src/app/main/ui/dashboard/sidebar.cljs:411, src/app/main/ui/dashboard/team.cljs:616, src/app/main/ui/dashboard/team.cljs:1191, src/app/main/ui/dashboard/team.cljs:1471, src/app/main/ui/dashboard/team.cljs:1577 -msgid "dashboard.your-penpot" -msgstr "Your Penpot" - -msgid "dashboard.my-files" -msgstr "My Files" +msgid "dashboard.personal-projects" +msgstr "Personal Projects" #: src/app/main/ui/alert.cljs:36 msgid "ds.alert-ok" @@ -1748,6 +1781,34 @@ msgstr "Confirmation password must match" msgid "errors.password-too-short" msgstr "Password should at least be 8 characters" +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password" +msgstr "Password does not meet the requirements" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.too-short" +msgstr "At least 8 characters" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.insufficient-lowercase" +msgstr "At least 1 lowercase letter" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.insufficient-uppercase" +msgstr "At least 1 uppercase letter" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.insufficient-digits" +msgstr "At least 1 digit" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.insufficient-special" +msgstr "At least 1 special character" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.in-dictionary" +msgstr "Password is too common" + #: src/app/main/errors.cljs:267 msgid "errors.paste-data-validation" msgstr "Invalid data in clipboard" @@ -2090,34 +2151,70 @@ msgid "files-download-modal.description-2" msgstr "* Might include components, graphics, colors and/or typographies." #: src/app/main/ui/exports/files.cljs:140 -msgid "files-download-modal.options.all.message" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.all.message" msgstr "" -"Files with shared libraries will be included in the export, maintaining " -"their linkage." + +#: src/app/main/ui/exports/files.cljs:140 +msgid "files-export-modal.options.include-libraries.message" +msgstr "" +"Files with linked libraries will be included in the export, maintaining " +"their linkage. " #: src/app/main/ui/exports/files.cljs:141 -msgid "files-download-modal.options.all.title" -msgstr "Export shared libraries" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.all.title" +msgstr "Export file + libraries" + +#: src/app/main/ui/exports/files.cljs:141 +msgid "files-export-modal.options.include-libraries.title" +msgstr "Export file + libraries" #: src/app/main/ui/exports/files.cljs:142 -msgid "files-download-modal.options.detach.message" -msgstr "" -"Shared libraries will not be included in the export and no assets will be " -"added to the library. " +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.detach.message" +msgstr "Linked library assets won't be included in the file." + +#: src/app/main/ui/exports/files.cljs:142 +msgid "files-export-modal.options.detach-libraries.message" +msgstr "Linked library assets won't be included in the file." + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.options.link-later.title" +msgstr "Link matching libraries on import" + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.options.link-later.message" +msgstr "When imported, you'll be able to link existing libraries with matching names." #: src/app/main/ui/exports/files.cljs:143 -msgid "files-download-modal.options.detach.title" -msgstr "Treat shared library assets as basic objects" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.detach.title" +msgstr "Treat assets as basic objects" + +#: src/app/main/ui/exports/files.cljs:143 +msgid "files-export-modal.options.detach-libraries.title" +msgstr "Treat assets as basic objects" #: src/app/main/ui/exports/files.cljs:144 -msgid "files-download-modal.options.merge.message" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.merge.message" msgstr "" -"Your file will be exported with all external assets merged into the file " + +#: src/app/main/ui/exports/files.cljs:144 +msgid "files-export-modal.options.merge-libraries.message" +msgstr "" +"Your file will be exported with library asset merged into the local " "library." #: src/app/main/ui/exports/files.cljs:145 -msgid "files-download-modal.options.merge.title" -msgstr "Include shared library assets in file libraries" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.merge.title" +msgstr "Embed library assets in the file" + +#: src/app/main/ui/exports/files.cljs:145 +msgid "files-export-modal.options.merge-libraries.title" +msgstr "Embed library assets in the file" #: src/app/main/ui/exports/files.cljs:123 msgid "files-download-modal.title" @@ -3583,6 +3680,51 @@ msgstr "Variant" msgid "labels.version-notes" msgstr "Version %s notes" +msgid "labels.check-for-updates" +msgstr "Check for updates" + +msgid "labels.checking-for-updates" +msgstr "Checking for updates..." + +msgid "dashboard.check-updates.available-title" +msgstr "A NEW PENPOT VERSION IS AVAILABLE" + +msgid "dashboard.check-updates.available-message" +msgstr "Newer releases include performance improvements, new capabilities, and fixes that keep your installation current." + +msgid "dashboard.check-updates.installed-version" +msgstr "Installed version" + +msgid "dashboard.check-updates.latest-version" +msgstr "Latest version" + +msgid "dashboard.check-updates.highlights-title" +msgstr "What's new - Highlights" + +msgid "dashboard.check-updates.view-changelog" +msgstr "View full changelog" + +msgid "dashboard.check-updates.view-release-notes" +msgstr "View release notes" + +msgid "dashboard.check-updates.unable-title" +msgstr "UNABLE TO CHECK FOR UPDATES" + +msgid "dashboard.check-updates.unable-message" +msgstr "This installation could not reach the Penpot releases service." + +msgid "dashboard.check-updates.unable-hint" +msgstr "Check your network or instance configuration and try again." + +msgid "dashboard.check-updates.try-again" +msgstr "Try again" + +msgid "dashboard.check-updates.uptodate-title" +msgstr "PENPOT IS UP TO DATE" + +msgid "dashboard.check-updates.uptodate-message" +msgstr "You're running the latest stable version:" + #: src/app/main/ui/workspace/sidebar/sitemap.cljs:298 msgid "labels.view-only" msgstr "View only" @@ -4349,6 +4491,9 @@ msgstr "This code has expired." msgid "nitrate.activation-code.invalid-error" msgstr "Invalid code." +msgid "nitrate.activation-code.used-error" +msgstr "This code has already been used." + #: src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs:57 msgid "nitrate.activation-success.active-until" msgstr "Your plan is active until %s." @@ -4372,14 +4517,17 @@ msgid "nitrate.modal-success.title" msgstr "Welcome to Enterprise!" #: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:104 -msgid "nitrate.code-activation.footer-before" -msgstr "Need a code? Download your " +msgid "nitrate.code-activation.footer-title" +msgstr "Need a code?" -msgid "nitrate.code-activation.footer-link" -msgstr "activation code request" +msgid "nitrate.code-activation.footer-download" +msgstr "Download request" msgid "nitrate.code-activation.footer-after" -msgstr " and contact us:" +msgstr "Send the file to" + +msgid "nitrate.code-activation.footer-before" +msgstr "and we will send you your code." #: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:86 msgid "nitrate.code-activation.input-label" @@ -4590,7 +4738,7 @@ msgstr "You don't have access to this file." #: src/app/main/ui/static.cljs:62, src/app/main/ui/static.cljs:265, src/app/main/ui/static.cljs:271, src/app/main/ui/static.cljs:277, src/app/main/ui/static.cljs:283, src/app/main/ui/static.cljs:292, src/app/main/ui/static.cljs:301 msgid "not-found.no-permission.go-dashboard" -msgstr "Go to your Penpot" +msgstr "Go to Personal Projects" #: src/app/main/ui/static.cljs:289, src/app/main/ui/static.cljs:298 msgid "not-found.no-permission.if-approves" @@ -5297,6 +5445,14 @@ msgid "shortcuts.delete-node" msgstr "Delete node" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:103 +msgid "shortcuts.delete-node-and-segments" +msgstr "Delete node and segments" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:103 +msgid "shortcuts.deselect-all" +msgstr "Deselect all" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:104 msgid "shortcuts.detach-component" msgstr "Detach component" @@ -6045,12 +6201,6 @@ msgstr "" "The payment was not completed. Please try again. " "If the problem persists, contact us: support@penpot.app." -#: src/app/main/ui/settings/subscription.cljs:407 -msgid "subscription.error.nitrate.checkout-finish-failed" -msgstr "" -"We couldn’t confirm your subscription. Please check your subscription " -"status on the Subscription page. You may try again if needed." - #: src/app/main/ui/settings/sidebar.cljs:114, src/app/main/ui/settings/subscription.cljs:505, src/app/main/ui/settings/subscription.cljs:565 msgid "subscription.labels" msgstr "Subscription" @@ -7670,10 +7820,30 @@ msgstr "Remove export" msgid "workspace.options.export.suffix" msgstr "Suffix" +#: src/app/main/ui/exports/assets.cljs:325 +msgid "workspace.options.cancel-export" +msgstr "Cancel" + #: src/app/main/ui/exports/assets.cljs:252 msgid "workspace.options.exporting-complete" msgstr "Export complete" +#: src/app/main/ui/exports/assets.cljs:259 +msgid "workspace.options.exporting-cancelled" +msgstr "Export cancelled" + +#: src/app/main/ui/exports/assets.cljs:258 +msgid "workspace.options.exporting-cancelling" +msgstr "Cancelling..." + +#: src/app/main/ui/exports/assets.cljs:261 +msgid "workspace.options.exporting-queued" +msgstr "Waiting..." + +#: src/app/main/ui/exports/assets.cljs:256 +msgid "workspace.options.exporting-busy" +msgstr "Export service is busy, please try again later" + #: src/app/main/ui/exports/assets.cljs:171, src/app/main/ui/exports/assets.cljs:253, src/app/main/ui/inspect/exports.cljs:216, src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:273 msgid "workspace.options.exporting-object" msgstr "Exporting…" @@ -8761,6 +8931,22 @@ msgstr "To corner (%s)" msgid "workspace.path.actions.make-curve" msgstr "To curve (%s)" +#: src/app/main/ui/workspace/viewport/path_actions.cljs +msgid "workspace.path.actions.handler-type" +msgstr "Handler behaviour" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs +msgid "workspace.path.actions.handler-mirror" +msgstr "Equal" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs +msgid "workspace.path.actions.handler-aligned" +msgstr "Aligned" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs +msgid "workspace.path.actions.handler-independent" +msgstr "Independent" + #: src/app/main/ui/workspace/viewport/path_actions.cljs:181 msgid "workspace.path.actions.merge-nodes" msgstr "Merge nodes (%s)" @@ -10311,3 +10497,15 @@ msgstr "Click to close the path" msgid "notifications.invitation-canceled" msgstr "This invitation is no longer available." + +msgid "labels.sso-error.title" +msgstr "We couldn't sign you in to %s" + +msgid "labels.sso-error.desc-message" +msgstr "Sign-in with your organization's identity provider didn't complete. The provider may be unavailable, or your account may not be in its directory yet." + +msgid "labels.sso-error.retry" +msgstr "Try again" + +msgid "dashboard.invite-profile-disabled" +msgstr "You don't have permission to invite people to this team" \ No newline at end of file diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 1d051dbaf4..6d586c8bea 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -395,6 +395,9 @@ msgstr "" msgid "dashboard.change-organization-modal.title" msgstr "Cambiar el equipo de organización" +msgid "dashboard.change-organization-modal.description" +msgstr "Los proyectos y archivos permanecerán disponibles para los miembros del equipo. El equipo obtendrá la configuración de la nueva organización." + #: src/app/main/ui/dashboard/deleted.cljs:316 msgid "dashboard.clear-trash-button" msgstr "Vaciar papelera" @@ -417,7 +420,7 @@ msgstr "Guardar esta versión" #: src/app/main/ui/components/context_menu_a11y.cljs:300, src/app/main/ui/dashboard/sidebar.cljs:882 msgid "dashboard.default-team-name" -msgstr "Tu Penpot" +msgstr "Proyectos Personales" #: src/app/main/ui/dashboard/deleted.cljs:265 msgid "dashboard.delete-all-forever-confirmation.description" @@ -765,6 +768,35 @@ msgstr[1] "%s ficheros se han importado correctamente." msgid "dashboard.import.import-warning" msgstr "Algunos ficheros contenían objetos erroneos que no han sido importados." +msgid "dashboard.import.auto-linked-libraries" +msgid_plural "dashboard.import.auto-linked-libraries" +msgstr[0] "1 biblioteca fue vinculada automáticamente por nombre." +msgstr[1] "%s bibliotecas fueron vinculadas automáticamente por nombre." + +msgid "dashboard.import.resolve-libraries" +msgstr "Algunas bibliotecas no pudieron vincularse automáticamente. Selecciona la biblioteca correcta para cada una:" + +msgid "dashboard.import.confirm-library-links" +msgstr "Confirmar vínculos de biblioteca" + +msgid "dashboard.import.resolve-libraries-summary" +msgstr "Revisa los vínculos de biblioteca antes de confirmar:" + +msgid "dashboard.import.review-links" +msgstr "Revisar vínculos" + +msgid "dashboard.import.summary.auto-linked" +msgstr "Vinculadas automáticamente" + +msgid "dashboard.import.summary.your-selection" +msgstr "Tu selección" + +msgid "dashboard.import.summary.linked" +msgstr "Vinculada" + +msgid "dashboard.import.summary.no-selection" +msgstr "Ninguna biblioteca seleccionada" + #: src/app/main/ui/dashboard.cljs:260 msgid "dashboard.import.no-perms" msgstr "No tienes permisos para importar en este equipo" @@ -876,8 +908,8 @@ msgid "dashboard.move-to-other-team" msgstr "Mover a otro equipo" #: src/app/main/ui/dashboard/sidebar.cljs:348, src/app/main/ui/dashboard/sidebar.cljs:349, src/app/main/ui/dashboard/sidebar.cljs:761 -msgid "dashboard.my-teams" -msgstr "Mis Equipos" +msgid "dashboard.other-teams" +msgstr "Otros equipos" #: src/app/main/ui/dashboard/files.cljs:106, src/app/main/ui/dashboard/projects.cljs:253, src/app/main/ui/dashboard/projects.cljs:254 msgid "dashboard.new-file" @@ -1118,11 +1150,11 @@ msgstr "" msgid "dashboard.select-organization-modal.external-invitations-will-be-canceled" msgstr "Las invitaciones pendientes a usuarios externos serán canceladas." -#, unused msgid "dashboard.select-organization-modal.permission-info" -msgstr "" -"Aquí encontrarás todas las organizaciones en las que tienes permiso para " -"crear o añadir equipos." +msgstr "Aquí encontrarás las organizaciones de las que eres parte donde tienes permiso para mover el equipo." + +msgid "dashboard.select-organization-modal.permission-info-add" +msgstr "Aquí encontrarás las organizaciones de las que eres parte donde tienes permiso para crear o añadir equipos." #, unused msgid "dashboard.select-organization-modal.select" @@ -1254,7 +1286,7 @@ msgstr "Tu proyecto ha sido movido con éxito" #, unused msgid "dashboard.team-belong-organization" -msgstr "Este equipo ahora pertenece a la organización %s" +msgstr "Este equipo ahora es parte de la organización %s" #: src/app/main/ui/dashboard/team.cljs:1602 msgid "dashboard.team-info" @@ -1266,7 +1298,7 @@ msgstr "Integrantes del equipo" #, unused msgid "dashboard.team-no-longer-belong-organization" -msgstr "Este equipo ya no pertenece a la organización %s" +msgstr "Este equipo ya no es parte de la organización %s" #: src/app/main/ui/dashboard/team.cljs:1609 msgid "dashboard.team-organization" @@ -1441,11 +1473,8 @@ msgid "dashboard.your-name" msgstr "Tu nombre" #: src/app/main/ui/dashboard/file_menu.cljs:40, src/app/main/ui/dashboard/fonts.cljs:46, src/app/main/ui/dashboard/libraries.cljs:55, src/app/main/ui/dashboard/projects.cljs:352, src/app/main/ui/dashboard/search.cljs:47, src/app/main/ui/dashboard/sidebar.cljs:411, src/app/main/ui/dashboard/team.cljs:616, src/app/main/ui/dashboard/team.cljs:1191, src/app/main/ui/dashboard/team.cljs:1471, src/app/main/ui/dashboard/team.cljs:1577 -msgid "dashboard.your-penpot" -msgstr "Tu Penpot" - -msgid "dashboard.my-files" -msgstr "Mis Archivos" +msgid "dashboard.personal-projects" +msgstr "Proyectos Personales" #: src/app/main/ui/alert.cljs:36 msgid "ds.alert-ok" @@ -1717,6 +1746,34 @@ msgstr "La contraseña de confirmación debe coincidir" msgid "errors.password-too-short" msgstr "La contraseña debe tener 8 caracteres como mínimo" +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password" +msgstr "La contraseña no cumple los requisitos" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.too-short" +msgstr "Al menos 8 caracteres" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.insufficient-lowercase" +msgstr "Al menos 1 letra minúscula" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.insufficient-uppercase" +msgstr "Al menos 1 letra mayúscula" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.insufficient-digits" +msgstr "Al menos 1 dígito" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.insufficient-special" +msgstr "Al menos 1 carácter especial" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.in-dictionary" +msgstr "La contraseña es demasiado común" + #: src/app/main/errors.cljs:267 msgid "errors.paste-data-validation" msgstr "Datos inválidos en el portapapeles" @@ -2038,33 +2095,70 @@ msgid "files-download-modal.description-2" msgstr "* Pueden incluir components, gráficos, colores y/o tipografias." #: src/app/main/ui/exports/files.cljs:140 -msgid "files-download-modal.options.all.message" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.all.message" msgstr "" -"Ficheros con librerias compartidas se inclurán en el paquete de exportación " + +#: src/app/main/ui/exports/files.cljs:140 +msgid "files-export-modal.options.include-libraries.message" +msgstr "" +"Los ficheros con librerias compartidas se inclurán en el paquete de exportación " "y mantendrán los enlaces." #: src/app/main/ui/exports/files.cljs:141 -msgid "files-download-modal.options.all.title" -msgstr "Exportar librerias compartidas" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.all.title" +msgstr "Exportar archivo + librerías" + +#: src/app/main/ui/exports/files.cljs:141 +msgid "files-export-modal.options.include-libraries.title" +msgstr "Exportar archivo + librerías" #: src/app/main/ui/exports/files.cljs:142 -msgid "files-download-modal.options.detach.message" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.detach.message" msgstr "" -"Las biblioteca compartidas no se incluirán en la exportación y ningún " -"recurso será incluido en la biblioteca. " + +#: src/app/main/ui/exports/files.cljs:142 +msgid "files-export-modal.options.detach-libraries.message" +msgstr "" +"Las recursos de las bibliotecas compartidas no se incluirán en la exportación." + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.options.link-later.title" +msgstr "Vincular librerías al importar" + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.options.link-later.message" +msgstr "Al importar, podrás vincular bibliotecas existentes con el mismo nombre." #: src/app/main/ui/exports/files.cljs:143 -msgid "files-download-modal.options.detach.title" -msgstr "Usar los recursos como objetos básicos" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.detach.title" +msgstr "Tratar los recursos como objetos básicos" + +#: src/app/main/ui/exports/files.cljs:143 +msgid "files-export-modal.options.detach-libraries.title" +msgstr "Tratar los recursos como objetos básicos" #: src/app/main/ui/exports/files.cljs:144 -msgid "files-download-modal.options.merge.message" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.merge.message" msgstr "" -"Tu fichero será exportado con todos los recursos dentro de la libreria del " + +#: src/app/main/ui/exports/files.cljs:144 +msgid "files-export-modal.options.merge-libraries.message" +msgstr "" +"Tu fichero será exportado con todos los recursos externos dentro de la libreria del " "propio fichero." #: src/app/main/ui/exports/files.cljs:145 -msgid "files-download-modal.options.merge.title" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.merge.title" +msgstr "Incluir librerias compartidas dentro de las librerias del fichero" + +#: src/app/main/ui/exports/files.cljs:145 +msgid "files-export-modal.options.merge-libraries.title" msgstr "Incluir librerias compartidas dentro de las librerias del fichero" #: src/app/main/ui/exports/files.cljs:123 @@ -3476,6 +3570,51 @@ msgstr "Variante" msgid "labels.version-notes" msgstr "Notas versión %s" +msgid "labels.check-for-updates" +msgstr "Comprobar actualizaciones" + +msgid "labels.checking-for-updates" +msgstr "Comprobando actualizaciones..." + +msgid "dashboard.check-updates.available-title" +msgstr "HAY UNA NUEVA VERSIÓN DE PENPOT DISPONIBLE" + +msgid "dashboard.check-updates.available-message" +msgstr "Las versiones más recientes incluyen mejoras de rendimiento, nuevas capacidades y correcciones que mantienen tu instalación al día." + +msgid "dashboard.check-updates.installed-version" +msgstr "Versión instalada" + +msgid "dashboard.check-updates.latest-version" +msgstr "Última versión" + +msgid "dashboard.check-updates.highlights-title" +msgstr "Novedades - Destacados" + +msgid "dashboard.check-updates.view-changelog" +msgstr "Ver changelog completo" + +msgid "dashboard.check-updates.view-release-notes" +msgstr "Ver notas de la versión" + +msgid "dashboard.check-updates.unable-title" +msgstr "NO HA SIDO POSIBLE COMPROBAR LAS ACTUALIZACIONES" + +msgid "dashboard.check-updates.unable-message" +msgstr "Esta instalación no pudo contactar con el servicio de versiones de Penpot." + +msgid "dashboard.check-updates.unable-hint" +msgstr "Comprueba tu red o la configuración de la instancia e inténtalo de nuevo." + +msgid "dashboard.check-updates.try-again" +msgstr "Intentar de nuevo" + +msgid "dashboard.check-updates.uptodate-title" +msgstr "PENPOT ESTÁ ACTUALIZADO" + +msgid "dashboard.check-updates.uptodate-message" +msgstr "Estás usando la última versión estable:" + #: src/app/main/ui/workspace/sidebar/sitemap.cljs:298 msgid "labels.view-only" msgstr "Solo lectura" @@ -4224,6 +4363,9 @@ msgstr "Este código ha caducado." msgid "nitrate.activation-code.invalid-error" msgstr "Código inválido." +msgid "nitrate.activation-code.used-error" +msgstr "Este código ya ha sido utilizado." + #: src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs:57 msgid "nitrate.activation-success.active-until" msgstr "Tu plan está activo hasta el %s." @@ -4247,14 +4389,17 @@ msgid "nitrate.modal-success.title" msgstr "¡Bienvenido a Enterprise!" #: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:104 -msgid "nitrate.code-activation.footer-before" -msgstr "¿Necesitas un código? Descarga tu " +msgid "nitrate.code-activation.footer-title" +msgstr "¿Necesitas un código?" -msgid "nitrate.code-activation.footer-link" -msgstr "solicitud de código de activación" +msgid "nitrate.code-activation.footer-download" +msgstr " Descargar solicitud" msgid "nitrate.code-activation.footer-after" -msgstr " y contáctanos:" +msgstr "Mánda el fichero a" + +msgid "nitrate.code-activation.footer-before" +msgstr "y te enviaremos tu código." #: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:86 msgid "nitrate.code-activation.input-label" @@ -4466,7 +4611,7 @@ msgstr "No tienes permiso para acceder a este archivo." #: src/app/main/ui/static.cljs:62, src/app/main/ui/static.cljs:265, src/app/main/ui/static.cljs:271, src/app/main/ui/static.cljs:277, src/app/main/ui/static.cljs:283, src/app/main/ui/static.cljs:292, src/app/main/ui/static.cljs:301 msgid "not-found.no-permission.go-dashboard" -msgstr "Ir a tu Penpot" +msgstr "Ir a tus Proyectos Personales" #: src/app/main/ui/static.cljs:289, src/app/main/ui/static.cljs:298 msgid "not-found.no-permission.if-approves" @@ -5164,6 +5309,13 @@ msgid "shortcuts.delete-node" msgstr "Borrar nodo" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:103 +msgid "shortcuts.delete-node-and-segments" +msgstr "Borrar nodo y segmentos" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:103 +msgid "shortcuts.deselect-all" +msgstr "Deseleccionar todo" + msgid "shortcuts.detach-component" msgstr "Desvincular componente" @@ -5909,13 +6061,6 @@ msgstr "" "No hemos podido iniciar el proceso de pago. Inténtalo de nuevo. Si el " "problema persiste, contáctanos: support@penpot.app." -#: src/app/main/ui/settings/subscription.cljs:407 -msgid "subscription.error.nitrate.checkout-finish-failed" -msgstr "" -"No hemos podido confirmar tu suscripción. Revisa el estado de tu " -"suscripción en la página de Suscripciones. Puedes volver a intentarlo si lo " -"necesitas." - #: src/app/main/ui/settings/sidebar.cljs:114, src/app/main/ui/settings/subscription.cljs:505, src/app/main/ui/settings/subscription.cljs:565 msgid "subscription.labels" msgstr "Suscripción" @@ -7464,10 +7609,30 @@ msgstr "Eliminar exportación" msgid "workspace.options.export.suffix" msgstr "Sufijo" +#: src/app/main/ui/exports/assets.cljs:325 +msgid "workspace.options.cancel-export" +msgstr "Cancelar" + #: src/app/main/ui/exports/assets.cljs:252 msgid "workspace.options.exporting-complete" msgstr "Exportación completa" +#: src/app/main/ui/exports/assets.cljs:259 +msgid "workspace.options.exporting-cancelled" +msgstr "Exportación cancelada" + +#: src/app/main/ui/exports/assets.cljs:258 +msgid "workspace.options.exporting-cancelling" +msgstr "Cancelando..." + +#: src/app/main/ui/exports/assets.cljs:261 +msgid "workspace.options.exporting-queued" +msgstr "Esperando..." + +#: src/app/main/ui/exports/assets.cljs:256 +msgid "workspace.options.exporting-busy" +msgstr "La cola de exportación está llena, inténtalo de nuevo en unos momentos" + #: src/app/main/ui/exports/assets.cljs:171, src/app/main/ui/exports/assets.cljs:253, src/app/main/ui/inspect/exports.cljs:216, src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:273 msgid "workspace.options.exporting-object" msgstr "Exportando…" @@ -9623,6 +9788,22 @@ msgstr "Comentarios (%s)" msgid "workspace.toolbar.curve" msgstr "Curva (%s)" +#: src/app/main/ui/workspace/viewport/path_actions.cljs +msgid "workspace.path.actions.handler-type" +msgstr "Comportamiento manejador" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs +msgid "workspace.path.actions.handler-mirror" +msgstr "Igual" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs +msgid "workspace.path.actions.handler-aligned" +msgstr "Alineado" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs +msgid "workspace.path.actions.handler-independent" +msgstr "Independiente" + #: src/app/main/ui/workspace/top_toolbar.cljs:231 #, unused msgid "workspace.toolbar.debug" @@ -9961,3 +10142,15 @@ msgstr "Pulsar para cerrar la ruta" msgid "notifications.invitation-canceled" msgstr "Esta invitación ya no está disponible." + +msgid "labels.sso-error.title" +msgstr "No pudimos iniciar sesión en %s" + +msgid "labels.sso-error.desc-message" +msgstr "El inicio de sesión con el proveedor de identidad de tu organización no se completó. Es posible que el proveedor no esté disponible o que tu cuenta aún no esté en su directorio." + +msgid "labels.sso-error.retry" +msgstr "Intentar de nuevo" + +msgid "dashboard.invite-profile-disabled" +msgstr "No tienes permiso para invitar a personas a este equipo" \ No newline at end of file diff --git a/library/README.md b/library/README.md index 9d76a1e3eb..d381e818b0 100644 --- a/library/README.md +++ b/library/README.md @@ -75,6 +75,6 @@ This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. -Copyright (c) KALEIDOS INC Sucursal en España SL +Copyright (c) KALEIDOS SUBSIDIARY SL ``` diff --git a/library/package.json b/library/package.json index 48fa9671b7..f22f9aea27 100644 --- a/library/package.json +++ b/library/package.json @@ -3,7 +3,7 @@ "version": "1.2.0-RC1", "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "type": "module", "repository": { "type": "git", @@ -36,7 +36,7 @@ "devDependencies": { "@types/node": "^26.1.2", "@zip.js/zip.js": "2.8.34", - "concurrently": "^10.0.4", + "concurrently": "^10.0.5", "date-fns": "^4.4.0", "nodemon": "^3.1.14", "source-map-support": "^0.5.21" diff --git a/library/pnpm-lock.yaml b/library/pnpm-lock.yaml index 63128ed4cc..6fa97dcff3 100644 --- a/library/pnpm-lock.yaml +++ b/library/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- lockfileVersion: '9.0' settings: @@ -18,8 +119,8 @@ importers: specifier: 2.8.34 version: 2.8.34(patch_hash=7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95) concurrently: - specifier: ^10.0.4 - version: 10.0.4 + specifier: ^10.0.5 + version: 10.0.5 date-fns: specifier: ^4.4.0 version: 4.4.0 @@ -59,9 +160,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -82,8 +183,8 @@ packages: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} - concurrently@10.0.4: - resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==} + concurrently@10.0.5: + resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==} engines: {node: '>=22'} hasBin: true @@ -274,7 +375,7 @@ snapshots: binary-extensions@2.3.0: {} - brace-expansion@5.0.7: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -304,7 +405,7 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 - concurrently@10.0.4: + concurrently@10.0.5: dependencies: chalk: 5.6.2 rxjs: 7.8.2 @@ -358,7 +459,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.9 ms@2.1.3: {} diff --git a/library/pnpm-workspace.yaml b/library/pnpm-workspace.yaml index bd4e87df02..b5e864413d 100644 --- a/library/pnpm-workspace.yaml +++ b/library/pnpm-workspace.yaml @@ -1,2 +1,4 @@ +minimumReleaseAgeExclude: + - brace-expansion@5.0.8 || 5.0.9 patchedDependencies: '@zip.js/zip.js@2.8.34': patches/@zip.js__zip.js@2.8.11.patch diff --git a/library/src/lib/builder.cljs b/library/src/lib/builder.cljs index ba279feb63..e860adcd81 100644 --- a/library/src/lib/builder.cljs +++ b/library/src/lib/builder.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns lib.builder (:require diff --git a/library/src/lib/export.cljs b/library/src/lib/export.cljs index 7ce561fa12..bfb9a2c778 100644 --- a/library/src/lib/export.cljs +++ b/library/src/lib/export.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns lib.export "A .penpot export implementation" diff --git a/manage.sh b/manage.sh index be6cc078b1..76ef2b1415 100755 --- a/manage.sh +++ b/manage.sh @@ -223,8 +223,10 @@ function ensure-devenv-network { # those stale values would leak into substitution. And because Docker Compose # gives shell-env precedence over --env-file, the re-injected per-instance # overrides cleanly override the defaults.env baseline. Re-injected: HOME/PATH -# (tooling), CURRENT_USER_ID/PENPOT_SOURCE_PATH (always per-call), and the -# instance-env-overrides block. +# (tooling), CURRENT_USER_ID/PENPOT_SOURCE_PATH (always per-call), the +# optional PENPOT_OPENCODE_CONFIG_DIR (set only by run-devenv's +# --opencode-config-dir within this process), and the instance-env-overrides +# block. function infra-compose { env -i HOME="$HOME" PATH="$PATH" PWD="$PWD" \ docker compose -p penpotdev-infra \ @@ -245,14 +247,26 @@ function instance-compose { # Per-instance overrides apply to all workspaces uniformly. mapfile -t overrides < <(instance-env-overrides "$instance") + # Optional personal-opencode-config overlay: the extra -f and variable + # are only present when run-devenv --opencode-config-dir resolved a host + # directory into PENPOT_OPENCODE_CONFIG_DIR; when unset neither the file + # nor the variable is referenced, so default behaviour is unchanged. + local -a compose_files=(-f docker/devenv/docker-compose.main.yml) + local -a opencode_env=() + if [[ -n "${PENPOT_OPENCODE_CONFIG_DIR:-}" ]]; then + compose_files+=(-f docker/devenv/docker-compose.opencode.yml) + opencode_env=("PENPOT_OPENCODE_CONFIG_DIR=${PENPOT_OPENCODE_CONFIG_DIR}") + fi + env -i HOME="$HOME" PATH="$PATH" PWD="$PWD" \ CURRENT_USER_ID="${CURRENT_USER_ID:-$(id -u)}" \ PENPOT_SOURCE_PATH="$source_path" \ DEVENV_TAG="$DEVENV_TAG" \ + "${opencode_env[@]}" \ "${overrides[@]}" \ docker compose -p "penpotdev-${instance}" \ --env-file "$DEVENV_DEFAULTS_FILE" \ - -f docker/devenv/docker-compose.main.yml \ + "${compose_files[@]}" \ "$@" } @@ -658,6 +672,26 @@ function parse-ws-integer { echo "ws$raw" } +# Strict parser for --opencode-config-dir. Resolves the value to an absolute +# host directory (docker compose resolves relative bind-mount sources against +# the compose file's directory, not $PWD, so relative values would be +# misinterpreted) and verifies it exists. Echoes the absolute path; anything +# else fails fast. +function parse-opencode-config-dir { + local raw="$1" + if [[ -z "$raw" ]]; then + echo "Invalid --opencode-config-dir: value is empty." >&2 + return 1 + fi + raw="${raw/#\~/$HOME}" + local abs + if ! abs=$(realpath -e "$raw" 2>/dev/null) || [[ ! -d "$abs" ]]; then + echo "Invalid --opencode-config-dir: '$raw' is not an existing directory." >&2 + return 1 + fi + echo "$abs" +} + # Bring a single instance up: compose up + detached tmux start. When agentic # is true (the default) the tmux session gets MCP + Serena enabled; when false @@ -764,6 +798,7 @@ function run-devenv { local serena_context="desktop-app" local git_user_name="" local git_user_email="" + local opencode_config_dir="" local -a extra_env_args=() while [[ $# -gt 0 ]]; do @@ -778,6 +813,8 @@ function run-devenv { do_attach=true; shift;; --serena-context) serena_context="$2"; shift 2;; + --opencode-config-dir) + opencode_config_dir="$(parse-opencode-config-dir "$2")" || return 1; shift 2;; --git-user-name) git_user_name="$2"; shift 2;; --git-user-email) @@ -787,13 +824,16 @@ function run-devenv { -e*) extra_env_args+=(-e "${1#-e}"); shift;; -h|--help) - echo "Usage: run-devenv [--ws N] [--sync] [--attach] [--agentic] [--serena-context CTX] [--git-user-name NAME] [--git-user-email EMAIL] [-e KEY=VAL]" + echo "Usage: run-devenv [--ws N] [--sync] [--attach] [--agentic] [--serena-context CTX] [--opencode-config-dir DIR] [--git-user-name NAME] [--git-user-email EMAIL] [-e KEY=VAL]" echo " Bring a single workspace up." echo " --ws N target workspace (default: 0)." echo " --sync re-seed the wsN clone from the live repo (forbidden on ws0)." echo " --attach attach to the tmux session after startup." echo " --agentic enable MCP + Serena (AI-agent mode)." echo " --serena-context CTX context passed to Serena (default: desktop-app)." + echo " --opencode-config-dir DIR bind-mount DIR at ~/.config/opencode inside the" + echo " container (personal agents/prompts/skills kept in a" + echo " separate repo). Applied at container creation." echo " --git-user-name NAME git author name inside the container (default: host git config)." echo " --git-user-email EMAIL git author email inside the container." echo " -e KEY=VAL forward env var to docker exec on attach." @@ -863,6 +903,14 @@ function run-devenv { write-instance-mcp-configs "$target" fi + # Scope the personal opencode config to this process only: instance-compose + # includes the overlay compose file and the variable just when it is set, + # so instances brought up without the flag mount nothing. + if [[ -n "$opencode_config_dir" ]]; then + echo "[$target] mounting personal opencode config: $opencode_config_dir -> ~/.config/opencode" + export PENPOT_OPENCODE_CONFIG_DIR="$opencode_config_dir" + fi + echo "Starting $target..." start-instance "$target" "$serena_context" "$git_user_name" "$git_user_email" "$agentic" print-instance-info "$target" @@ -1146,7 +1194,7 @@ This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. -Copyright (c) KALEIDOS INC Sucursal en España SL +Copyright (c) KALEIDOS SUBSIDIARY SL EOF } @@ -1338,6 +1386,11 @@ function usage { echo " --attach attach to the tmux session after startup." echo " --agentic enable MCP + Serena (AI-agent mode)." echo " --serena-context CTX passed to Serena (default: desktop-app)." + echo " --opencode-config-dir DIR" + echo " bind-mount DIR over the container's" + echo " ~/.config/opencode (personal opencode" + echo " agents/prompts/skills kept outside this" + echo " repo; applied at container creation)." echo " -e KEY=VAL forwarded to 'docker exec' on attach." echo " --git-user-name NAME / --git-user-email EMAIL" echo " identity wired into the container's git config" diff --git a/mcp/README.md b/mcp/README.md index 9b4ac77038..1b8dc3ea29 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -265,6 +265,7 @@ The Penpot MCP server can be configured using environment variables. | `PENPOT_MCP_SERVER_PORT` | Port for the HTTP/SSE server | `4401` | | `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` | | `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` | +| `PENPOT_MCP_REPL_ENABLE` | Explicitly enable/disable the REPL server. Set to `true` to enable. When unset, defaults to the value of `PENPOT_MCP_DEVENV`. | (unset) | | `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` | | `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools. Set to `true` to enable. | `false` | | `PENPOT_MCP_TOOL_TIMEOUT_S` | Timeout, in seconds, for tool calls dispatched to the Penpot plugin | `120` | diff --git a/mcp/package.json b/mcp/package.json index 20064d1a38..2b0046f3b0 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -23,9 +23,9 @@ "type": "git", "url": "https://github.com/penpot/penpot.git" }, - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "devDependencies": { - "concurrently": "^10.0.4", + "concurrently": "^10.0.5", "prettier": "^3.9.6" } } diff --git a/mcp/packages/common/package.json b/mcp/packages/common/package.json index 81e4449b60..4c82e9796a 100644 --- a/mcp/packages/common/package.json +++ b/mcp/packages/common/package.json @@ -4,7 +4,7 @@ "description": "Shared type definitions and interfaces for Penpot MCP", "main": "dist/index.js", "types": "dist/index.d.ts", - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "scripts": { "build": "tsc --build --clean && tsc --build", "watch": "tsc --watch", diff --git a/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json index bd8c45e49e..5377e1bba7 100644 --- a/mcp/packages/plugin/package.json +++ b/mcp/packages/plugin/package.json @@ -18,7 +18,7 @@ "devDependencies": { "cross-env": "^10.1.0", "typescript": "^6.0.3", - "vite": "^8.1.5", + "vite": "^8.2.0", "vite-live-preview": "^0.4.0" } } diff --git a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts index df2fb65832..87ea2c7616 100644 --- a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts +++ b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts @@ -214,6 +214,17 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> { let result: any; try { + // wait for layout updates prior to executing the supplied code (if method is available) + try { + // @ts-ignore - TODO Penpot.waitForLayoutUpdate is not yet in the released types + if (penpot.waitForLayoutUpdate) { + // @ts-ignore + await penpot.waitForLayoutUpdate(); + } + } catch (e) { + console.error("Error waiting for layout update:", e); + } + // execute the code in an async function with the context variables as parameters result = await (async (ctx) => { const fn = new Function(...Object.keys(ctx), `return (async () => { ${code} })();`); diff --git a/mcp/packages/plugin/vite.release.config.ts b/mcp/packages/plugin/vite.release.config.ts index 7156a2f85c..8449280032 100644 --- a/mcp/packages/plugin/vite.release.config.ts +++ b/mcp/packages/plugin/vite.release.config.ts @@ -1,5 +1,5 @@ import { defineConfig, mergeConfig } from "vite"; -import baseConfig from "./vite.config"; +import baseConfig from "./vite.config.ts"; export default mergeConfig( baseConfig, diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index 34aaf95d16..be9177619c 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -16,6 +16,14 @@ This is the full list of types/interfaces in the Penpot API: $api_types You use the `storage` object extensively to store data and utility functions you define across tool calls. This allows you to inspect intermediate results while still being able to build on them in subsequent code executions. +## Asynchronous Updates + +Changes made to a design may take effect asynchronously. +So if you need to read the result of your executions/observe properties affected by a change in the same `execude_code` call, use +`await penpot.waitForLayoutUpdate();` +before trying to observe changes. +Every `execude_code` call waits for updates before applying your code, so you never need to call `waitForLayoutUpdate` at the beginning of your code. + # The Structure of Penpot Designs A Penpot design ultimately consists of shapes. @@ -144,7 +152,7 @@ Boards can have layout systems that automatically control the positioning and sp it only changes the formal bounding box; if the text does not fit it, it will overflow; use `textBounds` for the actual bounding box of the rendered text. * Property `bounds` is sized automatically (in one dimension) if the `growType` property is set to "auto-width" or "auto-height". `resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-width" or "auto-height" if you want automatic sizing! - The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box. + The auto-sizing is asynchronous; use `waitForLayoutUpdate` before reading the updated bounding box. * Method `getRange(start, end): TextRange` to reference a range of characters as a `TextRange` object, which can be styled separately from the rest of the text; `start` index inclusive, `end` exclusive * Other Writable font properties: `fontId`, `fontFamily`, `fontWeight`, `fontVariant`, `fontStyle` - To discover valid values, check available fonts in `penpot.fonts: FontContext` @@ -391,7 +399,7 @@ Applying tokens: - TokenTextDecorationProps: "textDecoration" - TokenTypographyProps: "typography" * `token.applyToShapes(shapes, properties)` - Apply from token - * Application is **asynchronous** (wait for ~100ms to see the effects) + * Application is **asynchronous** (use `waitForLayoutUpdate`) * After application: - `shape.tokens` returns a mapping `{ propertyName: "token.name" }` from `TokenProperty` to token name - The actual shape properties that the tokens control will reflect the token's resolved value. diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json index 4dd4dd0b6c..e675c2e7c8 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -24,14 +24,14 @@ ], "author": "", "license": "MIT", - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "express": "^5.2.1", - "ioredis": "^5.11.1", - "js-yaml": "^5.2.2", + "ioredis": "^6.0.0", + "js-yaml": "^5.2.3", "nrepl-client": "^0.3.0", "pino": "^10.3.1", "pino-loki": "^3.0.0", @@ -45,12 +45,12 @@ "@penpot/mcp-common": "workspace:../common", "@types/express": "^5.0.6", "@types/js-yaml": "^4.0.9", - "@types/node": "^26.0.1", + "@types/node": "^26.1.2", "@types/ws": "^8.18.1", "cross-env": "^10.1.0", "esbuild": "^0.28.1", "ts-node": "^10.9.2", - "tsx": "^4.23.1", + "tsx": "^4.23.5", "typescript": "^6.0.3" }, "ts-node": { diff --git a/mcp/packages/server/src/PenpotMcpServer.test.ts b/mcp/packages/server/src/PenpotMcpServer.test.ts new file mode 100644 index 0000000000..5c04e50400 --- /dev/null +++ b/mcp/packages/server/src/PenpotMcpServer.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { PenpotMcpServer } from "./PenpotMcpServer"; + +// ── Pure function tests ──────────────────────────────────────── + +test("isDevEnvEnabled returns false when PENPOT_MCP_DEVENV is not set", () => { + assert.equal(PenpotMcpServer.isDevEnvEnabled({}), false); +}); + +test("isDevEnvEnabled returns false when PENPOT_MCP_DEVENV is 'false'", () => { + assert.equal(PenpotMcpServer.isDevEnvEnabled({ PENPOT_MCP_DEVENV: "false" }), false); +}); + +test("isDevEnvEnabled returns true when PENPOT_MCP_DEVENV is 'true'", () => { + assert.equal(PenpotMcpServer.isDevEnvEnabled({ PENPOT_MCP_DEVENV: "true" }), true); +}); + +// ── Pure function tests: isReplEnabled ────────────────────────── + +test("isReplEnabled returns false when neither env var is set", () => { + assert.equal(PenpotMcpServer.isReplEnabled({}), false); +}); + +test("isReplEnabled returns true when PENPOT_MCP_DEVENV is 'true' (fallback)", () => { + assert.equal(PenpotMcpServer.isReplEnabled({ PENPOT_MCP_DEVENV: "true" }), true); +}); + +test("isReplEnabled returns true when PENPOT_MCP_REPL_ENABLE is 'true'", () => { + assert.equal(PenpotMcpServer.isReplEnabled({ PENPOT_MCP_REPL_ENABLE: "true" }), true); +}); + +test("isReplEnabled returns false when PENPOT_MCP_REPL_ENABLE is 'false' even if DEVENV is true", () => { + assert.equal(PenpotMcpServer.isReplEnabled({ PENPOT_MCP_REPL_ENABLE: "false", PENPOT_MCP_DEVENV: "true" }), false); +}); + +test("isReplEnabled returns true when PENPOT_MCP_REPL_ENABLE is 'true' regardless of DEVENV", () => { + assert.equal(PenpotMcpServer.isReplEnabled({ PENPOT_MCP_REPL_ENABLE: "true" }), true); +}); + +// ── Integration tests: constructor gating ────────────────────── +// +// Each test uses unique ports to avoid conflicts when tests run +// in the same process. The server is stopped in the finally block +// to release the WebSocket port. + +let portCounter = 14_500; +function uniquePorts() { + const base = portCounter; + portCounter += 10; + return { server: base, ws: base + 1, repl: base + 2 }; +} + +test("constructor does not create ReplServer when PENPOT_MCP_DEVENV is unset", async () => { + const prev = process.env.PENPOT_MCP_DEVENV; + const prevPorts = setUniqueEnv(); + delete process.env.PENPOT_MCP_DEVENV; + let server: PenpotMcpServer | undefined; + try { + server = new PenpotMcpServer(false); + assert.equal(server.hasReplServer(), false); + } finally { + await server?.stop(); + restoreEnv(prev, prevPorts); + } +}); + +test("constructor creates ReplServer when PENPOT_MCP_DEVENV is 'true'", async () => { + const prev = process.env.PENPOT_MCP_DEVENV; + const prevPorts = setUniqueEnv(); + process.env.PENPOT_MCP_DEVENV = "true"; + let server: PenpotMcpServer | undefined; + try { + server = new PenpotMcpServer(false); + assert.equal(server.hasReplServer(), true); + } finally { + await server?.stop(); + restoreEnv(prev, prevPorts); + } +}); + +test("constructor creates ReplServer when PENPOT_MCP_REPL_ENABLE is 'true' without DEVENV", async () => { + const prevDevEnv = process.env.PENPOT_MCP_DEVENV; + const prevReplEnable = process.env.PENPOT_MCP_REPL_ENABLE; + const prevPorts = setUniqueEnv(); + delete process.env.PENPOT_MCP_DEVENV; + process.env.PENPOT_MCP_REPL_ENABLE = "true"; + let server: PenpotMcpServer | undefined; + try { + server = new PenpotMcpServer(false); + assert.equal(server.hasReplServer(), true); + } finally { + await server?.stop(); + restoreEnv(prevDevEnv, prevPorts); + restoreOrDelete("PENPOT_MCP_REPL_ENABLE", prevReplEnable); + } +}); + +test("constructor does not create ReplServer when PENPOT_MCP_REPL_ENABLE is 'false' even with DEVENV", async () => { + const prevDevEnv = process.env.PENPOT_MCP_DEVENV; + const prevReplEnable = process.env.PENPOT_MCP_REPL_ENABLE; + const prevPorts = setUniqueEnv(); + process.env.PENPOT_MCP_DEVENV = "true"; + process.env.PENPOT_MCP_REPL_ENABLE = "false"; + let server: PenpotMcpServer | undefined; + try { + server = new PenpotMcpServer(false); + assert.equal(server.hasReplServer(), false); + } finally { + await server?.stop(); + restoreEnv(prevDevEnv, prevPorts); + restoreOrDelete("PENPOT_MCP_REPL_ENABLE", prevReplEnable); + } +}); + +// ── Helpers ──────────────────────────────────────────────────── + +function setUniqueEnv() { + const ports = uniquePorts(); + const prevServer = process.env.PENPOT_MCP_SERVER_PORT; + const prevWs = process.env.PENPOT_MCP_WEBSOCKET_PORT; + const prevRepl = process.env.PENPOT_MCP_REPL_PORT; + process.env.PENPOT_MCP_SERVER_PORT = String(ports.server); + process.env.PENPOT_MCP_WEBSOCKET_PORT = String(ports.ws); + process.env.PENPOT_MCP_REPL_PORT = String(ports.repl); + return { prevServer, prevWs, prevRepl }; +} + +function restoreEnv( + devEnv: string | undefined, + ports: { prevServer: string | undefined; prevWs: string | undefined; prevRepl: string | undefined } +) { + if (devEnv !== undefined) { + process.env.PENPOT_MCP_DEVENV = devEnv; + } else { + delete process.env.PENPOT_MCP_DEVENV; + } + restoreOrDelete("PENPOT_MCP_SERVER_PORT", ports.prevServer); + restoreOrDelete("PENPOT_MCP_WEBSOCKET_PORT", ports.prevWs); + restoreOrDelete("PENPOT_MCP_REPL_PORT", ports.prevRepl); +} + +function restoreOrDelete(key: string, value: string | undefined) { + if (value !== undefined) { + process.env[key] = value; + } else { + delete process.env[key]; + } +} diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index bd992ec108..09849c9316 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -56,6 +56,30 @@ export class PenpotMcpServer { */ private static readonly SESSION_TIMEOUT_MINUTES = 60; + /** + * Determines whether the server is running in a Penpot development + * environment, based on the given environment variables. + * + * Returns ``true`` only when ``PENPOT_MCP_DEVENV`` is ``"true"``. + */ + public static isDevEnvEnabled(env: Record<string, string | undefined>): boolean { + return env.PENPOT_MCP_DEVENV === "true"; + } + + /** + * Determines whether the REPL server should be enabled. + * + * If ``PENPOT_MCP_REPL_ENABLE`` is set, its value controls the result + * (``"true"`` enables, any other value disables). When the variable is + * not set, the result falls back to {@link isDevEnvEnabled}. + */ + public static isReplEnabled(env: Record<string, string | undefined>): boolean { + if (env.PENPOT_MCP_REPL_ENABLE !== undefined) { + return env.PENPOT_MCP_REPL_ENABLE === "true"; + } + return PenpotMcpServer.isDevEnvEnabled(env); + } + /** * Returns a short, non-reversible fingerprint of a user token, suitable for * correlating log lines without exposing the full credential. @@ -83,7 +107,7 @@ export class PenpotMcpServer { public readonly configLoader: ConfigurationLoader; private app: any; public readonly pluginBridge: PluginBridge; - private readonly replServer: ReplServer; + private readonly replServer: ReplServer | null; private apiDocs: ApiDocs; private readonly penpotHighLevelOverview: string; private readonly connectionInstructions: string; @@ -149,7 +173,12 @@ export class PenpotMcpServer { } this.pluginBridge = new PluginBridge(this, this.webSocketPort, toolTimeoutSecs, this.redisBridge); - this.replServer = new ReplServer(this.pluginBridge, this.replPort, this.host); + + if (PenpotMcpServer.isReplEnabled(process.env)) { + this.replServer = new ReplServer(this.pluginBridge, this.replPort, this.host); + } else { + this.replServer = null; + } } /** @@ -190,7 +219,18 @@ export class PenpotMcpServer { * additional developer tools such as ClojureScript expression evaluation are exposed. */ public isDevEnv(): boolean { - return process.env.PENPOT_MCP_DEVENV === "true"; + return PenpotMcpServer.isDevEnvEnabled(process.env); + } + + /** + * Indicates whether the REPL server was created. + * + * The REPL server is created when {@link isReplEnabled} returns true, + * which means either ``PENPOT_MCP_REPL_ENABLE=true`` or, when that + * variable is unset, ``PENPOT_MCP_DEVENV=true``. + */ + public hasReplServer(): boolean { + return this.replServer !== null; } /** @@ -421,8 +461,14 @@ export class PenpotMcpServer { this.logger.info(`Legacy SSE endpoint: http://${this.host}:${this.port}/sse`); this.logger.info(`WebSocket server URL: ws://${this.host}:${this.webSocketPort}`); - // start the REPL server and session timeout checker - await this.replServer.start(); + // start the REPL server (devenv only) and session timeout checker + if (this.replServer) { + await this.replServer.start(); + } else { + this.logger.info( + "REPL server disabled (set PENPOT_MCP_REPL_ENABLE=true or PENPOT_MCP_DEVENV=true to enable)" + ); + } this.startSessionTimeoutChecker(); resolve(); @@ -438,8 +484,11 @@ export class PenpotMcpServer { public async stop(): Promise<void> { this.logger.info("Stopping Penpot MCP Server..."); clearInterval(this.sessionTimeoutInterval); + await this.pluginBridge.close(); await this.redisBridge?.close(); - await this.replServer.stop(); + if (this.replServer) { + await this.replServer.stop(); + } this.logger.info("Penpot MCP Server stopped"); } } diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index a362faef36..6a1e07f9c5 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -467,4 +467,16 @@ export class PluginBridge { task.rejectWithError(error instanceof Error ? error : new Error(String(error))); } } + + /** + * Closes the WebSocket server and all connected client sockets. + */ + public async close(): Promise<void> { + return new Promise((resolve) => { + this.wsServer.close(() => { + this.logger.info("WebSocket server closed"); + resolve(); + }); + }); + } } diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 9b657ed4a2..706069eee7 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -1,16 +1,120 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- lockfileVersion: '9.0' settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@hono/node-server@<2.0.5': ^2.0.5 + importers: .: devDependencies: concurrently: - specifier: ^10.0.4 - version: 10.0.4 + specifier: ^10.0.5 + version: 10.0.5 prettier: specifier: ^3.9.6 version: 3.9.6 @@ -37,17 +141,17 @@ importers: specifier: ^6.0.3 version: 6.0.3 vite: - specifier: ^8.1.5 - version: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.23.1) + specifier: ^8.2.0 + version: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12) vite-live-preview: specifier: ^0.4.0 - version: 0.4.0(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.23.1)) + version: 0.4.0(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12)) packages/server: dependencies: '@modelcontextprotocol/sdk': specifier: ^1.29.0 - version: 1.29.0(supports-color@10.2.2)(zod@4.4.3) + version: 1.30.0(supports-color@10.2.2)(zod@4.4.3) class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -58,11 +162,11 @@ importers: specifier: ^5.2.1 version: 5.2.1(supports-color@10.2.2) ioredis: - specifier: ^5.11.1 - version: 5.11.1(supports-color@10.2.2) + specifier: ^6.0.0 + version: 6.0.0(supports-color@10.2.2) js-yaml: - specifier: ^5.2.2 - version: 5.2.2 + specifier: ^5.2.3 + version: 5.4.1 nrepl-client: specifier: ^0.3.0 version: 0.3.0 @@ -80,10 +184,10 @@ importers: version: 0.2.2 sharp: specifier: ^0.35.3 - version: 0.35.3(@types/node@26.0.1) + version: 0.35.4(@types/node@26.4.0) ws: specifier: ^8.21.1 - version: 8.21.1 + version: 8.21.3 zod: specifier: ^4.4.3 version: 4.4.3 @@ -98,8 +202,8 @@ importers: specifier: ^4.0.9 version: 4.0.9 '@types/node': - specifier: ^26.0.1 - version: 26.0.1 + specifier: ^26.1.2 + version: 26.4.0 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -108,13 +212,13 @@ importers: version: 10.1.0 esbuild: specifier: ^0.28.1 - version: 0.28.1 + version: 0.28.2 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@26.0.1)(typescript@6.0.3) + version: 10.9.2(@types/node@26.4.0)(typescript@6.0.3) tsx: - specifier: ^4.23.1 - version: 4.23.1 + specifier: ^4.23.5 + version: 4.23.12 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -125,180 +229,171 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} - '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} - - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} - '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} - '@emnapi/wasi-threads@1.2.2': - resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@hono/node-server@1.19.14': - resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} - engines: {node: '>=18.14.1'} + '@hono/node-server@2.1.1': + resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} + engines: {node: '>=20'} peerDependencies: hono: ^4 @@ -306,166 +401,166 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.3': - resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.3': - resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.3': - resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.2': - resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.2': - resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.2': - resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.2': - resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.2': - resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.2': - resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.2': - resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.2': - resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': - resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.2': - resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.3': - resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.3': - resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.3': - resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.3': - resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.3': - resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.3': - resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.3': - resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.3': - resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.3': - resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.3': - resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.3': - resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.3': - resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.3': - resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] - '@ioredis/commands@1.10.0': - resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@ioredis/commands@2.0.0': + resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==} '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} @@ -477,8 +572,8 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@modelcontextprotocol/sdk@1.29.0': - resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -487,15 +582,8 @@ packages: '@cfworker/json-schema': optional: true - '@napi-rs/wasm-runtime@1.2.1': - resolution: {integrity: sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} - peerDependencies: - '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 - '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 - - '@oxc-project/types@0.139.0': - resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} '@penpot/plugin-styles@1.5.0': resolution: {integrity: sha512-rwXFBRPd0IZs3ens+eRT8oA5WqkQgF6xlW6fCfH7U1NHRjej9HH7vTGXmfjoIVml6Xx1sbyYHZVxkQC8Ub8+zA==} @@ -506,97 +594,98 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@rolldown/binding-android-arm64@1.1.5': - resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + '@rolldown/binding-android-arm-eabi@1.2.6': + resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.6': + resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.5': - resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + '@rolldown/binding-darwin-arm64@1.2.6': + resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.5': - resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + '@rolldown/binding-darwin-x64@1.2.6': + resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.5': - resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + '@rolldown/binding-freebsd-x64@1.2.6': + resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': - resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.5': - resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + '@rolldown/binding-linux-arm64-gnu@1.2.6': + resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.5': - resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + '@rolldown/binding-linux-arm64-musl@1.2.6': + resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.5': - resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.5': - resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + '@rolldown/binding-linux-s390x-gnu@1.2.6': + resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.5': - resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + '@rolldown/binding-linux-x64-gnu@1.2.6': + resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.5': - resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + '@rolldown/binding-linux-x64-musl@1.2.6': + resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.5': - resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + '@rolldown/binding-openharmony-arm64@1.2.6': + resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.5': - resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.1.5': - resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + '@rolldown/binding-win32-arm64-msvc@1.2.6': + resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.5': - resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + '@rolldown/binding-win32-x64-msvc@1.2.6': + resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -610,8 +699,8 @@ packages: '@seahax/semaphore@0.5.1': resolution: {integrity: sha512-q6SXYYbE6X+LDcq2h2yCgE+pCWJumNP3XCZkztdG4S4tiig9akMZGp8TsfU/EIRcHWPdnQ3BA8/NAvdDYdF/NQ==} - '@tsconfig/node10@1.0.12': - resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + '@tsconfig/node10@1.0.13': + resolution: {integrity: sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==} '@tsconfig/node12@1.0.11': resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} @@ -622,17 +711,14 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - '@types/express-serve-static-core@5.1.1': - resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + '@types/express-serve-static-core@5.1.3': + resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==} '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} @@ -643,8 +729,8 @@ packages: '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} - '@types/node@26.0.1': - resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/node@26.4.0': + resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -668,12 +754,12 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} - acorn-walk@8.3.4: - resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true @@ -709,8 +795,8 @@ packages: bencode@2.0.3: resolution: {integrity: sha512-D/vrAD4dLVX23NalHwb8dSvsUsxeRPO8Y7ToKA015JQYq69MLDOMkC0uGZYA/MPpltLO8rt8eqFC2j8DxjTZ/w==} - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} bytes@3.1.2: @@ -746,19 +832,23 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - concurrently@10.0.4: - resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==} + concurrently@10.0.5: + resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==} engines: {node: '>=22'} hasBin: true - content-disposition@1.0.1: - resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -840,8 +930,8 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -860,16 +950,16 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - eventsource-parser@3.1.0: - resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} engines: {node: '>=18.0.0'} eventsource@3.0.7: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} - express-rate-limit@8.5.2: - resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + express-rate-limit@8.6.2: + resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -878,8 +968,8 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} - fast-copy@4.0.2: - resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} + fast-copy@4.0.4: + resolution: {integrity: sha512-eVAiWVNPSEGIzDl5yPuLrx8fNMogScXvD9xp1Kzd41FjRIz2I3sSIcxsFeM5EzFfHAfobdvs8ZySffUopljvIA==} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -887,8 +977,8 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-uri@3.1.6: + resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -950,27 +1040,27 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - hono@4.12.27: - resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} + hono@4.13.5: + resolution: {integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==} engines: {node: '>=16.9.0'} http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ioredis@5.11.1: - resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} - engines: {node: '>=12.22.0'} + ioredis@6.0.0: + resolution: {integrity: sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==} + engines: {node: '>=20.0.0'} - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + ip-address@10.5.0: + resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -983,15 +1073,15 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - jose@6.2.3: - resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + jose@6.2.10: + resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} - js-yaml@5.2.2: - resolution: {integrity: sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==} + js-yaml@5.4.1: + resolution: {integrity: sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==} hasBin: true json-schema-traverse@1.0.0: @@ -1000,8 +1090,8 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} - libphonenumber-js@1.13.7: - resolution: {integrity: sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==} + libphonenumber-js@1.13.11: + resolution: {integrity: sha512-ETER2kMaIFTI/Nh1a8Gk03dUF/SL0VZqtI+CcVHZxp5WIHYwNS7S+uiYZDYCvLy3lOR4/DAD5jf0h5WkePPpqg==} lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} @@ -1084,8 +1174,8 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} engines: {node: '>= 0.8'} merge-descriptors@2.0.0: @@ -1106,14 +1196,14 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} nrepl-client@0.3.0: resolution: {integrity: sha512-EcROXUrzlGHKOdu/E/5WB0OESCI0iGHhdXeYk9cULYtd72eFJrM/Q1umvjTBfKWlT62y76cnyLG/3CmSCqT12w==} @@ -1151,8 +1241,8 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} pino-abstract-transport@3.0.0: @@ -1178,8 +1268,8 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} - postcss@8.5.25: - resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} prettier@3.9.6: @@ -1187,16 +1277,13 @@ packages: engines: {node: '>=14'} hasBin: true - process-warning@5.0.0: - resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - pump@3.0.3: - resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} - pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -1207,8 +1294,8 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} raw-body@3.0.2: @@ -1226,10 +1313,6 @@ packages: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} - redis-parser@3.0.0: - resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} - engines: {node: '>=4'} - reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -1237,8 +1320,8 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - rolldown@1.1.5: - resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + rolldown@1.2.6: + resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1275,8 +1358,8 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - sharp@0.35.3: - resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -1312,9 +1395,6 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} - sonic-boom@4.2.0: - resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} - sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -1382,14 +1462,14 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.1: - resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} engines: {node: '>=18.0.0'} hasBin: true - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} @@ -1416,17 +1496,16 @@ packages: vite-live-preview@0.4.0: resolution: {integrity: sha512-Qz8kr0kixXwnQl+zLPZX66OjajN4jnVnDwhNToJsO6TTboUtBo8pEmRuc0iBmkwW9lXR8mOeMu+QtxFkXBcHYg==} - hasBin: true peerDependencies: vite: '>=5.4.0' - vite@8.1.5: - resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.3.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -1475,20 +1554,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - ws@8.21.1: - resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -1529,218 +1596,202 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@emnapi/core@1.11.1': - dependencies: - '@emnapi/wasi-threads': 1.2.2 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.11.1': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.2.2': - dependencies: - tslib: 2.8.1 - optional: true - '@epic-web/invariant@1.0.0': {} - '@esbuild/aix-ppc64@0.28.1': + '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.28.1': + '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.28.1': + '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.28.1': + '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.28.1': + '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.28.1': + '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.28.1': + '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.28.1': + '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.28.1': + '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.28.1': + '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.28.1': + '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.28.1': + '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.28.1': + '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.28.1': + '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.28.1': + '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.28.1': + '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.28.1': + '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.28.1': + '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.28.1': + '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.28.1': + '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.28.1': + '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.28.1': + '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.28.1': + '@esbuild/win32-x64@0.28.2': optional: true - '@hono/node-server@1.19.14(hono@4.12.27)': + '@hono/node-server@2.1.1(hono@4.13.5)': dependencies: - hono: 4.12.27 + hono: 4.13.5 '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.35.3': + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.35.3': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true - '@img/sharp-freebsd-wasm32@0.35.3': + '@img/sharp-freebsd-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.2': + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-libvips-darwin-x64@1.3.2': + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm64@1.3.2': + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm@1.3.2': + '@img/sharp-libvips-linux-arm@1.3.3': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.2': + '@img/sharp-libvips-linux-ppc64@1.3.3': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.2': + '@img/sharp-libvips-linux-riscv64@1.3.3': optional: true - '@img/sharp-libvips-linux-s390x@1.3.2': + '@img/sharp-libvips-linux-s390x@1.3.3': optional: true - '@img/sharp-libvips-linux-x64@1.3.2': + '@img/sharp-libvips-linux-x64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.2': + '@img/sharp-libvips-linuxmusl-x64@1.3.3': optional: true - '@img/sharp-linux-arm64@0.35.3': + '@img/sharp-linux-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.3 optional: true - '@img/sharp-linux-arm@0.35.3': + '@img/sharp-linux-arm@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.3 optional: true - '@img/sharp-linux-ppc64@0.35.3': + '@img/sharp-linux-ppc64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.3 optional: true - '@img/sharp-linux-riscv64@0.35.3': + '@img/sharp-linux-riscv64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.3 optional: true - '@img/sharp-linux-s390x@0.35.3': + '@img/sharp-linux-s390x@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.3 optional: true - '@img/sharp-linux-x64@0.35.3': + '@img/sharp-linux-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.3 optional: true - '@img/sharp-linuxmusl-arm64@0.35.3': + '@img/sharp-linuxmusl-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 optional: true - '@img/sharp-linuxmusl-x64@0.35.3': + '@img/sharp-linuxmusl-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 optional: true - '@img/sharp-wasm32@0.35.3': + '@img/sharp-wasm32@0.35.4': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.3': + '@img/sharp-webcontainers-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-win32-arm64@0.35.3': + '@img/sharp-win32-arm64@0.35.4': optional: true - '@img/sharp-win32-ia32@0.35.3': + '@img/sharp-win32-ia32@0.35.4': optional: true - '@img/sharp-win32-x64@0.35.3': + '@img/sharp-win32-x64@0.35.4': optional: true - '@ioredis/commands@1.10.0': {} + '@ioredis/commands@2.0.0': {} '@jridgewell/resolve-uri@3.1.2': {} @@ -1751,20 +1802,20 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3)': + '@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)': dependencies: - '@hono/node-server': 1.19.14(hono@4.12.27) + '@hono/node-server': 2.1.1(hono@4.13.5) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 cors: 2.8.6 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.1.0 + eventsource-parser: 3.1.1 express: 5.2.1(supports-color@10.2.2) - express-rate-limit: 8.5.2(express@5.2.1(supports-color@10.2.2)) - hono: 4.12.27 - jose: 6.2.3 + express-rate-limit: 8.6.2(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2) + hono: 4.13.5 + jose: 6.2.10 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -1773,14 +1824,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@napi-rs/wasm-runtime@1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 - optional: true - - '@oxc-project/types@0.139.0': {} + '@oxc-project/types@0.147.0': {} '@penpot/plugin-styles@1.5.0': {} @@ -1788,53 +1832,49 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@rolldown/binding-android-arm64@1.1.5': + '@rolldown/binding-android-arm-eabi@1.2.6': optional: true - '@rolldown/binding-darwin-arm64@1.1.5': + '@rolldown/binding-android-arm64@1.2.6': optional: true - '@rolldown/binding-darwin-x64@1.1.5': + '@rolldown/binding-darwin-arm64@1.2.6': optional: true - '@rolldown/binding-freebsd-x64@1.1.5': + '@rolldown/binding-darwin-x64@1.2.6': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + '@rolldown/binding-freebsd-x64@1.2.6': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.5': + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.5': + '@rolldown/binding-linux-arm64-gnu@1.2.6': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.5': + '@rolldown/binding-linux-arm64-musl@1.2.6': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.5': + '@rolldown/binding-linux-ppc64-gnu@1.2.6': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.5': + '@rolldown/binding-linux-s390x-gnu@1.2.6': optional: true - '@rolldown/binding-linux-x64-musl@1.1.5': + '@rolldown/binding-linux-x64-gnu@1.2.6': optional: true - '@rolldown/binding-openharmony-arm64@1.1.5': + '@rolldown/binding-linux-x64-musl@1.2.6': optional: true - '@rolldown/binding-wasm32-wasi@1.1.5': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@rolldown/binding-openharmony-arm64@1.2.6': optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.5': + '@rolldown/binding-win32-arm64-msvc@1.2.6': optional: true - '@rolldown/binding-win32-x64-msvc@1.1.5': + '@rolldown/binding-win32-x64-msvc@1.2.6': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -1843,7 +1883,7 @@ snapshots: '@seahax/semaphore@0.5.1': {} - '@tsconfig/node10@1.0.12': {} + '@tsconfig/node10@1.0.13': {} '@tsconfig/node12@1.0.11': {} @@ -1851,23 +1891,18 @@ snapshots: '@tsconfig/node16@1.0.4': {} - '@tybys/wasm-util@0.10.3': - dependencies: - tslib: 2.8.1 - optional: true - '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 26.0.1 + '@types/node': 26.4.0 '@types/connect@3.4.38': dependencies: - '@types/node': 26.0.1 + '@types/node': 26.4.0 - '@types/express-serve-static-core@5.1.1': + '@types/express-serve-static-core@5.1.3': dependencies: - '@types/node': 26.0.1 + '@types/node': 26.4.0 '@types/qs': 6.15.1 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -1875,14 +1910,14 @@ snapshots: '@types/express@5.0.6': dependencies: '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 5.1.1 + '@types/express-serve-static-core': 5.1.3 '@types/serve-static': 2.2.0 '@types/http-errors@2.0.5': {} '@types/js-yaml@4.0.9': {} - '@types/node@26.0.1': + '@types/node@26.4.0': dependencies: undici-types: 8.3.0 @@ -1892,29 +1927,29 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 26.0.1 + '@types/node': 26.4.0 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 26.0.1 + '@types/node': 26.4.0 '@types/validator@13.15.10': {} '@types/ws@8.18.1': dependencies: - '@types/node': 26.0.1 + '@types/node': 26.4.0 accepts@2.0.0: dependencies: mime-types: 3.0.2 - negotiator: 1.0.0 + negotiator: 1.1.0 - acorn-walk@8.3.4: + acorn-walk@8.3.5: dependencies: - acorn: 8.15.0 + acorn: 8.18.0 - acorn@8.15.0: {} + acorn@8.18.0: {} ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: @@ -1923,7 +1958,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 3.1.6 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -1939,17 +1974,17 @@ snapshots: bencode@2.0.3: {} - body-parser@2.2.2(supports-color@10.2.2): + body-parser@2.3.0(supports-color@10.2.2): dependencies: bytes: 3.1.2 - content-type: 1.0.5 + content-type: 2.1.0 debug: 4.4.3(supports-color@10.2.2) http-errors: 2.0.1 - iconv-lite: 0.7.2 + iconv-lite: 0.7.3 on-finished: 2.4.1 qs: 6.15.3 raw-body: 3.0.2 - type-is: 2.0.1 + type-is: 2.1.0 transitivePeerDependencies: - supports-color @@ -1972,7 +2007,7 @@ snapshots: class-validator@0.15.1: dependencies: '@types/validator': 13.15.10 - libphonenumber-js: 1.13.7 + libphonenumber-js: 1.13.11 validator: 13.15.35 cliui@9.0.1: @@ -1985,7 +2020,7 @@ snapshots: colorette@2.0.20: {} - concurrently@10.0.4: + concurrently@10.0.5: dependencies: chalk: 5.6.2 rxjs: 7.8.2 @@ -1994,10 +2029,12 @@ snapshots: tree-kill: 1.2.2 yargs: 18.0.0 - content-disposition@1.0.1: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} + content-type@2.1.0: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -2060,34 +2097,34 @@ snapshots: dependencies: es-errors: 1.3.0 - esbuild@0.28.1: + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 escalade@3.2.0: {} @@ -2097,22 +2134,25 @@ snapshots: etag@1.8.1: {} - eventsource-parser@3.1.0: {} + eventsource-parser@3.1.1: {} eventsource@3.0.7: dependencies: - eventsource-parser: 3.1.0 + eventsource-parser: 3.1.1 - express-rate-limit@8.5.2(express@5.2.1(supports-color@10.2.2)): + express-rate-limit@8.6.2(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2): dependencies: + debug: 4.4.3(supports-color@10.2.2) express: 5.2.1(supports-color@10.2.2) - ip-address: 10.2.0 + ip-address: 10.5.0 + transitivePeerDependencies: + - supports-color express@5.2.1(supports-color@10.2.2): dependencies: accepts: 2.0.0 - body-parser: 2.2.2(supports-color@10.2.2) - content-disposition: 1.0.1 + body-parser: 2.3.0(supports-color@10.2.2) + content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 @@ -2131,27 +2171,27 @@ snapshots: parseurl: 1.3.3 proxy-addr: 2.0.7 qs: 6.15.3 - range-parser: 1.2.1 + range-parser: 1.3.0 router: 2.2.0(supports-color@10.2.2) send: 1.2.1(supports-color@10.2.2) serve-static: 2.2.1(supports-color@10.2.2) statuses: 2.0.2 - type-is: 2.0.1 + type-is: 2.1.0 vary: 1.1.2 transitivePeerDependencies: - supports-color - fast-copy@4.0.2: {} + fast-copy@4.0.4: {} fast-deep-equal@3.1.3: {} fast-safe-stringify@2.1.1: {} - fast-uri@3.1.2: {} + fast-uri@3.1.6: {} - fdir@6.5.0(picomatch@4.0.5): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.5 + picomatch: 4.0.7 finalhandler@2.1.1(supports-color@10.2.2): dependencies: @@ -2205,7 +2245,7 @@ snapshots: help-me@5.0.0: {} - hono@4.12.27: {} + hono@4.13.5: {} http-errors@2.0.1: dependencies: @@ -2215,25 +2255,24 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - iconv-lite@0.7.2: + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 inherits@2.0.4: {} - ioredis@5.11.1(supports-color@10.2.2): + ioredis@6.0.0(supports-color@10.2.2): dependencies: - '@ioredis/commands': 1.10.0 + '@ioredis/commands': 2.0.0 cluster-key-slot: 1.1.1 debug: 4.4.3(supports-color@10.2.2) denque: 2.1.0 redis-errors: 1.2.0 - redis-parser: 3.0.0 standard-as-callback: 2.1.0 transitivePeerDependencies: - supports-color - ip-address@10.2.0: {} + ip-address@10.5.0: {} ipaddr.js@1.9.1: {} @@ -2241,11 +2280,11 @@ snapshots: isexe@2.0.0: {} - jose@6.2.3: {} + jose@6.2.10: {} joycon@3.1.1: {} - js-yaml@5.2.2: + js-yaml@5.4.1: dependencies: argparse: 2.0.1 @@ -2253,7 +2292,7 @@ snapshots: json-schema-typed@8.0.2: {} - libphonenumber-js@1.13.7: {} + libphonenumber-js@1.13.11: {} lightningcss-android-arm64@1.33.0: optional: true @@ -2308,7 +2347,7 @@ snapshots: math-intrinsics@1.1.0: {} - media-typer@1.1.0: {} + media-typer@1.1.1: {} merge-descriptors@2.0.0: {} @@ -2322,9 +2361,11 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.16: {} + nanoid@3.3.18: {} - negotiator@1.0.0: {} + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 nrepl-client@0.3.0: dependencies: @@ -2353,7 +2394,7 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.5: {} + picomatch@4.0.7: {} pino-abstract-transport@3.0.0: dependencies: @@ -2368,16 +2409,16 @@ snapshots: dependencies: colorette: 2.0.20 dateformat: 4.6.3 - fast-copy: 4.0.2 + fast-copy: 4.0.4 fast-safe-stringify: 2.1.1 help-me: 5.0.0 joycon: 3.1.1 minimist: 1.2.8 on-exit-leak-free: 2.1.2 pino-abstract-transport: 3.0.0 - pump: 3.0.3 + pump: 3.0.4 secure-json-parse: 4.1.0 - sonic-boom: 4.2.0 + sonic-boom: 4.2.1 strip-json-comments: 5.0.3 pino-std-serializers@7.1.0: {} @@ -2389,7 +2430,7 @@ snapshots: on-exit-leak-free: 2.1.2 pino-abstract-transport: 3.0.0 pino-std-serializers: 7.1.0 - process-warning: 5.0.0 + process-warning: 5.1.0 quick-format-unescaped: 4.0.4 real-require: 0.2.0 safe-stable-stringify: 2.5.0 @@ -2398,26 +2439,21 @@ snapshots: pkce-challenge@5.0.1: {} - postcss@8.5.25: + postcss@8.5.26: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 prettier@3.9.6: {} - process-warning@5.0.0: {} + process-warning@5.1.0: {} proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 - pump@3.0.3: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -2430,13 +2466,13 @@ snapshots: quick-format-unescaped@4.0.4: {} - range-parser@1.2.1: {} + range-parser@1.3.0: {} raw-body@3.0.2: dependencies: bytes: 3.1.2 http-errors: 2.0.1 - iconv-lite: 0.7.2 + iconv-lite: 0.7.3 unpipe: 1.0.0 real-require@0.2.0: {} @@ -2445,34 +2481,30 @@ snapshots: redis-errors@1.2.0: {} - redis-parser@3.0.0: - dependencies: - redis-errors: 1.2.0 - reflect-metadata@0.2.2: {} require-from-string@2.0.2: {} - rolldown@1.1.5: + rolldown@1.2.6: dependencies: - '@oxc-project/types': 0.139.0 + '@oxc-project/types': 0.147.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.5 - '@rolldown/binding-darwin-arm64': 1.1.5 - '@rolldown/binding-darwin-x64': 1.1.5 - '@rolldown/binding-freebsd-x64': 1.1.5 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 - '@rolldown/binding-linux-arm64-gnu': 1.1.5 - '@rolldown/binding-linux-arm64-musl': 1.1.5 - '@rolldown/binding-linux-ppc64-gnu': 1.1.5 - '@rolldown/binding-linux-s390x-gnu': 1.1.5 - '@rolldown/binding-linux-x64-gnu': 1.1.5 - '@rolldown/binding-linux-x64-musl': 1.1.5 - '@rolldown/binding-openharmony-arm64': 1.1.5 - '@rolldown/binding-wasm32-wasi': 1.1.5 - '@rolldown/binding-win32-arm64-msvc': 1.1.5 - '@rolldown/binding-win32-x64-msvc': 1.1.5 + '@rolldown/binding-android-arm-eabi': 1.2.6 + '@rolldown/binding-android-arm64': 1.2.6 + '@rolldown/binding-darwin-arm64': 1.2.6 + '@rolldown/binding-darwin-x64': 1.2.6 + '@rolldown/binding-freebsd-x64': 1.2.6 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.6 + '@rolldown/binding-linux-arm64-gnu': 1.2.6 + '@rolldown/binding-linux-arm64-musl': 1.2.6 + '@rolldown/binding-linux-ppc64-gnu': 1.2.6 + '@rolldown/binding-linux-s390x-gnu': 1.2.6 + '@rolldown/binding-linux-x64-gnu': 1.2.6 + '@rolldown/binding-linux-x64-musl': 1.2.6 + '@rolldown/binding-openharmony-arm64': 1.2.6 + '@rolldown/binding-win32-arm64-msvc': 1.2.6 + '@rolldown/binding-win32-x64-msvc': 1.2.6 router@2.2.0(supports-color@10.2.2): dependencies: @@ -2507,7 +2539,7 @@ snapshots: mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 - range-parser: 1.2.1 + range-parser: 1.3.0 statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -2523,38 +2555,38 @@ snapshots: setprototypeof@1.2.0: {} - sharp@0.35.3(@types/node@26.0.1): + sharp@0.35.4(@types/node@26.4.0): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.3 - '@img/sharp-darwin-x64': 0.35.3 - '@img/sharp-freebsd-wasm32': 0.35.3 - '@img/sharp-libvips-darwin-arm64': 1.3.2 - '@img/sharp-libvips-darwin-x64': 1.3.2 - '@img/sharp-libvips-linux-arm': 1.3.2 - '@img/sharp-libvips-linux-arm64': 1.3.2 - '@img/sharp-libvips-linux-ppc64': 1.3.2 - '@img/sharp-libvips-linux-riscv64': 1.3.2 - '@img/sharp-libvips-linux-s390x': 1.3.2 - '@img/sharp-libvips-linux-x64': 1.3.2 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 - '@img/sharp-linux-arm': 0.35.3 - '@img/sharp-linux-arm64': 0.35.3 - '@img/sharp-linux-ppc64': 0.35.3 - '@img/sharp-linux-riscv64': 0.35.3 - '@img/sharp-linux-s390x': 0.35.3 - '@img/sharp-linux-x64': 0.35.3 - '@img/sharp-linuxmusl-arm64': 0.35.3 - '@img/sharp-linuxmusl-x64': 0.35.3 - '@img/sharp-webcontainers-wasm32': 0.35.3 - '@img/sharp-win32-arm64': 0.35.3 - '@img/sharp-win32-ia32': 0.35.3 - '@img/sharp-win32-x64': 0.35.3 - '@types/node': 26.0.1 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 26.4.0 shebang-command@2.0.0: dependencies: @@ -2592,10 +2624,6 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - sonic-boom@4.2.0: - dependencies: - atomic-sleep: 1.0.0 - sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -2628,23 +2656,23 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 toidentifier@1.0.1: {} tree-kill@1.2.2: {} - ts-node@10.9.2(@types/node@26.0.1)(typescript@6.0.3): + ts-node@10.9.2(@types/node@26.4.0)(typescript@6.0.3): dependencies: '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.12 + '@tsconfig/node10': 1.0.13 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 26.0.1 - acorn: 8.15.0 - acorn-walk: 8.3.4 + '@types/node': 26.4.0 + acorn: 8.18.0 + acorn-walk: 8.3.5 arg: 4.1.3 create-require: 1.1.1 diff: 4.0.4 @@ -2655,16 +2683,16 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.1: + tsx@4.23.12: dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 - type-is@2.0.1: + type-is@2.1.0: dependencies: - content-type: 1.0.5 - media-typer: 1.1.0 + content-type: 2.1.0 + media-typer: 1.1.1 mime-types: 3.0.2 typescript@6.0.3: {} @@ -2679,31 +2707,31 @@ snapshots: vary@1.1.2: {} - vite-live-preview@0.4.0(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.23.1)): + vite-live-preview@0.4.0(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12)): dependencies: '@seahax/deep-copy': 0.1.0 '@seahax/semaphore': 0.5.1 '@types/ws': 8.18.1 escape-goat: 4.0.0 strip-ansi: 7.2.0 - vite: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.23.1) - ws: 8.21.0 + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12) + ws: 8.21.3 transitivePeerDependencies: - bufferutil - utf-8-validate - vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.23.1): + vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12): dependencies: lightningcss: 1.33.0 - picomatch: 4.0.5 - postcss: 8.5.25 - rolldown: 1.1.5 + picomatch: 4.0.7 + postcss: 8.5.26 + rolldown: 1.2.6 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.0.1 - esbuild: 0.28.1 + '@types/node': 26.4.0 + esbuild: 0.28.2 fsevents: 2.3.3 - tsx: 4.23.1 + tsx: 4.23.12 which@2.0.2: dependencies: @@ -2717,9 +2745,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.21.0: {} - - ws@8.21.1: {} + ws@8.21.3: {} y18n@5.0.8: {} diff --git a/mcp/pnpm-workspace.yaml b/mcp/pnpm-workspace.yaml index 14eaeefa61..eb1c390c2f 100644 --- a/mcp/pnpm-workspace.yaml +++ b/mcp/pnpm-workspace.yaml @@ -1,8 +1,3 @@ -# auto-confirm node_modules purge when pnpm detects an incompatible modules -# directory (e.g. after a store location or pnpm major version change), -# preventing the interactive prompt from blocking bootstrap -confirmModulesPurge: false - allowBuilds: esbuild: true sharp: false @@ -12,8 +7,16 @@ linkWorkspacePackages: true minimumReleaseAgeExclude: - qs@6.14.2 || 6.15.2 - path-to-regexp@8.4.0 + - body-parser@2.3.0 + - '@hono/node-server@2.0.5' + - fast-uri@3.1.3 || 3.1.4 || 3.1.5 + - ip-address@10.2.1 || 10.2.2 || 10.3.1 + - hono@4.12.34 packages: - - "./packages/common" - - "./packages/server" - - "./packages/plugin" + - "packages/common" + - "packages/server" + - "packages/plugin" + +overrides: + '@hono/node-server@<2.0.5': ^2.0.5 diff --git a/media-processor/.prettierignore b/media-processor/.prettierignore new file mode 100644 index 0000000000..2d0c064480 --- /dev/null +++ b/media-processor/.prettierignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +coverage/ diff --git a/media-processor/.prettierrc b/media-processor/.prettierrc new file mode 100644 index 0000000000..5ebd5018e8 --- /dev/null +++ b/media-processor/.prettierrc @@ -0,0 +1,9 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "es5", + "printWidth": 120, + "endOfLine": "lf" +} diff --git a/media-processor/esbuild.config.mjs b/media-processor/esbuild.config.mjs new file mode 100644 index 0000000000..698edc38d4 --- /dev/null +++ b/media-processor/esbuild.config.mjs @@ -0,0 +1,17 @@ +import { build } from "esbuild"; + +await build({ + entryPoints: ["src/index.ts"], + bundle: true, + platform: "node", + target: "node24", + format: "esm", + outfile: "dist/index.js", + external: ["sharp", "pino", "pino-pretty", "pino-loki"], + banner: { + js: ` +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +`, + }, +}); diff --git a/media-processor/package.json b/media-processor/package.json new file mode 100644 index 0000000000..0bb5711cf7 --- /dev/null +++ b/media-processor/package.json @@ -0,0 +1,40 @@ +{ + "name": "media-processor", + "version": "1.0.0", + "description": "Stateless HTTP service for Penpot image and font processing", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "node esbuild.config.mjs", + "start": "node dist/index.js", + "start:dev": "tsx --env-file=../backend/scripts/_env src/index.ts", + "types:check": "tsc --noEmit", + "test": "vitest run", + "fmt": "prettier --write src/ test/", + "fmt:check": "prettier --check src/ test/", + "clean": "rm -rf dist/" + }, + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", + "dependencies": { + "express": "^5.2.1", + "multer": "^2.2.0", + "p-queue": "^9.3.3", + "pino": "^10.3.1", + "pino-loki": "^3.0.0", + "pino-pretty": "^13.1.3", + "sharp": "^0.35.4", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/multer": "^2.0.0", + "@types/node": "^26.3.0", + "@types/supertest": "^7.2.1", + "esbuild": "^0.28.2", + "prettier": "^3.6.2", + "supertest": "^7.2.2", + "tsx": "^4.23.12", + "typescript": "^7.0.2", + "vitest": "^4.1.11" + } +} diff --git a/media-processor/pnpm-lock.yaml b/media-processor/pnpm-lock.yaml new file mode 100644 index 0000000000..51eef68766 --- /dev/null +++ b/media-processor/pnpm-lock.yaml @@ -0,0 +1,2750 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + express: + specifier: ^5.2.1 + version: 5.2.1 + multer: + specifier: ^2.2.0 + version: 2.2.0 + p-queue: + specifier: ^9.3.3 + version: 9.3.3 + pino: + specifier: ^10.3.1 + version: 10.3.1 + pino-loki: + specifier: ^3.0.0 + version: 3.0.0 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 + sharp: + specifier: ^0.35.4 + version: 0.35.4(@types/node@26.3.0) + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/multer': + specifier: ^2.0.0 + version: 2.2.0 + '@types/node': + specifier: ^26.3.0 + version: 26.3.0 + '@types/supertest': + specifier: ^7.2.1 + version: 7.2.1 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 + prettier: + specifier: ^3.6.2 + version: 3.9.6 + supertest: + specifier: ^7.2.2 + version: 7.2.2 + tsx: + specifier: ^4.23.12 + version: 4.23.12 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vitest: + specifier: ^4.1.11 + version: 4.1.11(@types/node@26.3.0)(vite@7.3.6(@types/node@26.3.0)(tsx@4.23.12)) + +packages: + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/express-serve-static-core@5.1.2': + resolution: {integrity: sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/multer@2.2.0': + resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==} + + '@types/node@26.3.0': + resolution: {integrity: sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/superagent@8.1.11': + resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==} + + '@types/supertest@7.2.1': + resolution: {integrity: sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-copy@4.0.4: + resolution: {integrity: sha512-eVAiWVNPSEGIzDl5yPuLrx8fNMogScXvD9xp1Kzd41FjRIz2I3sSIcxsFeM5EzFfHAfobdvs8ZySffUopljvIA==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multer@2.2.0: + resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} + engines: {node: '>= 10.16.0'} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + p-queue@9.3.3: + resolution: {integrity: sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==} + engines: {node: '>=20'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-loki@3.0.0: + resolution: {integrity: sha512-9TyUW5syTjp2nT70QcijJtIWUzdYUj+olQ7+fWNfm1/HrDGEWt86Q4ACzClH6DM6GBwtQimRDgneNczP+p4ypA==} + engines: {node: '>=20'} + hasBin: true + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + postcss@8.5.20: + resolution: {integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.3 + optional: true + + '@img/sharp-darwin-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.3 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-win32-arm64@0.35.4': + optional: true + + '@img/sharp-win32-ia32@0.35.4': + optional: true + + '@img/sharp-win32-x64@0.35.4': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@noble/hashes@1.8.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@pinojs/redact@0.4.0': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 26.3.0 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 26.3.0 + + '@types/cookiejar@2.1.5': {} + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/express-serve-static-core@5.1.2': + dependencies: + '@types/node': 26.3.0 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.2 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/methods@1.1.4': {} + + '@types/multer@2.2.0': + dependencies: + '@types/express': 5.0.6 + + '@types/node@26.3.0': + dependencies: + undici-types: 8.3.0 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 26.3.0 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 26.3.0 + + '@types/superagent@8.1.11': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 26.3.0 + form-data: 4.0.6 + + '@types/supertest@7.2.1': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.11 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.11(vite@7.3.6(@types/node@26.3.0)(tsx@4.23.12))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@26.3.0)(tsx@4.23.12) + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + append-field@1.0.0: {} + + asap@2.0.6: {} + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + atomic-sleep@1.0.0: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + buffer-from@1.1.2: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chai@6.2.2: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + component-emitter@1.3.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookiejar@2.1.4: {} + + dateformat@4.6.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + detect-libc@2.1.2: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.1: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-html@1.0.3: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + etag@1.8.1: {} + + eventemitter3@5.0.4: {} + + expect-type@1.4.0: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-copy@4.0.4: {} + + fast-safe-stringify@2.1.1: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + help-me@5.0.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + joycon@3.1.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@2.6.0: {} + + minimist@1.2.8: {} + + ms@2.1.3: {} + + multer@2.2.0: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + + nanoid@3.3.16: {} + + negotiator@1.0.0: {} + + object-inspect@1.13.4: {} + + obug@2.1.4: {} + + on-exit-leak-free@2.1.2: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + p-queue@9.3.3: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.1 + + p-timeout@7.0.1: {} + + parseurl@1.3.3: {} + + path-to-regexp@8.4.2: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-loki@3.0.0: + dependencies: + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.4 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + postcss@8.5.20: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.9.6: {} + + process-warning@5.0.0: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + quick-format-unescaped@4.0.4: {} + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safe-buffer@5.2.1: {} + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + secure-json-parse@4.1.0: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + sharp@0.35.4(@types/node@26.3.0): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 26.3.0 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@4.2.0: {} + + streamsearch@1.1.0: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@5.0.3: {} + + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.6 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.3 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.0: {} + + toidentifier@1.0.1: {} + + tslib@2.8.1: + optional: true + + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typedarray@0.0.6: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@8.3.0: {} + + unpipe@1.0.0: {} + + util-deprecate@1.0.2: {} + + vary@1.1.2: {} + + vite@7.3.6(@types/node@26.3.0)(tsx@4.23.12): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.20 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.3.0 + fsevents: 2.3.3 + tsx: 4.23.12 + + vitest@4.1.11(@types/node@26.3.0)(vite@7.3.6(@types/node@26.3.0)(tsx@4.23.12)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@7.3.6(@types/node@26.3.0)(tsx@4.23.12)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 7.3.6(@types/node@26.3.0)(tsx@4.23.12) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.3.0 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrappy@1.0.2: {} + + zod@4.4.3: {} diff --git a/media-processor/pnpm-workspace.yaml b/media-processor/pnpm-workspace.yaml new file mode 100644 index 0000000000..5ed0b5af0d --- /dev/null +++ b/media-processor/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/media-processor/scripts/build b/media-processor/scripts/build new file mode 100755 index 0000000000..94ec8e856c --- /dev/null +++ b/media-processor/scripts/build @@ -0,0 +1,4 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")/.." +pnpm run build diff --git a/media-processor/scripts/setup b/media-processor/scripts/setup new file mode 100755 index 0000000000..c7be37d33d --- /dev/null +++ b/media-processor/scripts/setup @@ -0,0 +1,6 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")/.." +corepack enable +corepack install +pnpm install diff --git a/media-processor/src/config.ts b/media-processor/src/config.ts new file mode 100644 index 0000000000..c6229ae343 --- /dev/null +++ b/media-processor/src/config.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; +import { hkdfSync } from "node:crypto"; +import type { AppConfig } from "./types.js"; + +const envSchema = z.object({ + PENPOT_MEDIA_PROCESSOR_PORT: z.coerce.number().int().positive().default(6065), + PENPOT_MEDIA_PROCESSOR_HOST: z.string().default("0.0.0.0"), + PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS: z.coerce.number().int().min(1).default(10), + PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT: z.coerce.number().int().nonnegative().default(180000), + PENPOT_MEDIA_PROCESSOR_MAX_FILE_SIZE: z.coerce.number().int().positive().default(367001600), // 350 MB + PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD: z.coerce.number().int().positive().default(10485760), // 10 MB — uploads below this use memory storage; above use disk storage + PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_PIXELS: z.coerce.number().int().positive().default(128_000_000), + PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_WIDTH: z.coerce.number().int().positive().default(16384), + PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_HEIGHT: z.coerce.number().int().positive().default(16384), + PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_MEM: z.coerce.number().int().positive().default(512), + PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_CPU_TIME: z.coerce.number().int().positive().default(30), + PENPOT_MEDIA_PROCESSOR_FONT_TIMEOUT: z.coerce.number().int().positive().default(120000), + PENPOT_MEDIA_PROCESSOR_SHARED_KEY: z.string().optional(), + PENPOT_SECRET_KEY: z.string().optional(), + PENPOT_MEDIA_PROCESSOR_LOG_LEVEL: z + .enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]) + .default("info"), + PENPOT_LOGGERS_LOKI_URI: z.string().optional(), + PENPOT_LOGGERS_LOKI_JOB: z.string().default("media-processor"), + PENPOT_LOGGERS_LOKI_ENVIRONMENT: z.string().optional(), + PENPOT_LOGGERS_LOKI_INSTANCE: z.string().optional(), +}); + +function deriveSharedKey(secret: string): string { + const key = hkdfSync("blake2b512", secret, Buffer.from("media-processor"), "", 32); + return Buffer.from(key).toString("base64url"); +} + +export function loadConfig(): AppConfig { + const parsed = envSchema.parse(process.env); + + let sharedKey: string | null = null; + if (parsed.PENPOT_MEDIA_PROCESSOR_SHARED_KEY) { + sharedKey = parsed.PENPOT_MEDIA_PROCESSOR_SHARED_KEY; + } else if (parsed.PENPOT_SECRET_KEY) { + sharedKey = deriveSharedKey(parsed.PENPOT_SECRET_KEY); + } + + return { + port: parsed.PENPOT_MEDIA_PROCESSOR_PORT, + host: parsed.PENPOT_MEDIA_PROCESSOR_HOST, + maxConcurrentRequests: parsed.PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS, + requestTimeout: parsed.PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT, + maxFileSize: parsed.PENPOT_MEDIA_PROCESSOR_MAX_FILE_SIZE, + memoryThreshold: parsed.PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD, + imageMaxPixels: parsed.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_PIXELS, + imageMaxWidth: parsed.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_WIDTH, + imageMaxHeight: parsed.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_HEIGHT, + fontProcessMem: parsed.PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_MEM, + fontProcessCpuTime: parsed.PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_CPU_TIME, + fontTimeout: parsed.PENPOT_MEDIA_PROCESSOR_FONT_TIMEOUT, + sharedKey, + logLevel: parsed.PENPOT_MEDIA_PROCESSOR_LOG_LEVEL, + lokiUri: parsed.PENPOT_LOGGERS_LOKI_URI || null, + lokiJob: parsed.PENPOT_LOGGERS_LOKI_JOB, + lokiEnvironment: parsed.PENPOT_LOGGERS_LOKI_ENVIRONMENT || null, + lokiInstance: parsed.PENPOT_LOGGERS_LOKI_INSTANCE || null, + }; +} diff --git a/media-processor/src/index.ts b/media-processor/src/index.ts new file mode 100644 index 0000000000..ac05201659 --- /dev/null +++ b/media-processor/src/index.ts @@ -0,0 +1,59 @@ +import express, { type Express } from "express"; +import { loadConfig } from "./config.js"; +import { initLogger, logger, logActiveTransports } from "./logger.js"; +import { healthRoutes } from "./routes/health.js"; +import { createImageRoutes } from "./routes/image.js"; +import { createFontRoutes } from "./routes/font.js"; +import { errorHandler } from "./middleware/error-handler.js"; +import { timeoutMiddleware } from "./middleware/timeout.js"; +import { sharedKeyAuth } from "./middleware/auth.js"; +import { createQueueMiddleware } from "./middleware/queue.js"; +import { loggingMiddleware } from "./middleware/logging.js"; +import { configureImageLimits } from "./services/image.js"; +import { configureFontLimits } from "./services/font.js"; +import { configureUploadLimits } from "./upload.js"; +import sharp from "sharp"; + +// Auth is enforced via x-shared-key header (sharedKeyAuth middleware). +// When no key is configured, all requests are rejected (403). +// This service MUST be deployed on an internal Docker network only +// — do NOT expose to the public internet. + +// Disable sharp/libvips caching to prevent unbounded memory growth +sharp.cache(false); + +const config = loadConfig(); +initLogger(config); +const app: Express = express(); + +// Configure resource limits +configureImageLimits({ + maxPixels: config.imageMaxPixels, + maxWidth: config.imageMaxWidth, + maxHeight: config.imageMaxHeight, +}); + +configureFontLimits({ + mem: config.fontProcessMem, + cpuTime: config.fontProcessCpuTime, + timeout: config.fontTimeout, +}); + +configureUploadLimits({ maxFileSize: config.maxFileSize, memoryThreshold: config.memoryThreshold }); + +const queueMiddleware = createQueueMiddleware(config.maxConcurrentRequests); + +app.use(timeoutMiddleware(config.requestTimeout)); +app.use(loggingMiddleware); + +app.get("/api/health", healthRoutes); +app.use("/api/image", sharedKeyAuth(config.sharedKey), queueMiddleware, createImageRoutes()); +app.use("/api/font", sharedKeyAuth(config.sharedKey), queueMiddleware, createFontRoutes()); +app.use(errorHandler); + +app.listen(config.port, config.host, () => { + logActiveTransports(logger); + logger.info(`media-processor listening on ${config.host}:${config.port}`); +}); + +export { app }; diff --git a/media-processor/src/logger.ts b/media-processor/src/logger.ts new file mode 100644 index 0000000000..aede1219d2 --- /dev/null +++ b/media-processor/src/logger.ts @@ -0,0 +1,135 @@ +import pino, { type TransportTargetOptions } from "pino"; +import { loadConfig } from "./config.js"; +import type { AppConfig } from "./types.js"; + +interface LogTransportProvider { + getTarget(): TransportTargetOptions | null; + getStartupMessage(): string | null; +} + +class ConsoleLogTransport implements LogTransportProvider { + public constructor(private readonly config: AppConfig) {} + + public getTarget(): TransportTargetOptions { + return { + target: "pino-pretty", + level: this.config.logLevel, + options: { + colorize: true, + translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l", + ignore: "pid,hostname", + messageFormat: "{msg}", + levelFirst: true, + }, + }; + } + + public getStartupMessage(): string { + return "Logging to console"; + } +} + +class LokiLogTransport implements LogTransportProvider { + private readonly host: string | null; + + public constructor( + private readonly config: AppConfig, + lokiUri: string | null + ) { + this.host = lokiUri; + } + + public getTarget(): TransportTargetOptions | null { + if (this.host === null) { + return null; + } + return { + target: "pino-loki", + level: this.config.logLevel, + options: { + host: this.host, + json: false, + batching: true, + interval: 5, + replaceTimestamp: true, + labels: this.buildLabels(), + messageFormat: "{msg}", + ignore: "pid,hostname", + }, + }; + } + + private buildLabels(): Record<string, string> { + const labels: Record<string, string> = { + job: this.config.lokiJob, + }; + if (this.config.lokiEnvironment) { + labels.environment = this.config.lokiEnvironment; + } + if (this.config.lokiInstance) { + labels.instance = this.config.lokiInstance; + } + return labels; + } + + public getStartupMessage(): string | null { + return this.host !== null ? `Logging to Loki: ${this.host}` : null; + } +} + +function buildLogger(config: AppConfig) { + const consoleTransport = new ConsoleLogTransport(config); + const lokiTransport = new LokiLogTransport(config, config.lokiUri); + const transports: LogTransportProvider[] = [consoleTransport, lokiTransport]; + + const instance = pino({ + level: config.logLevel, + timestamp: pino.stdTimeFunctions.isoTime, + transport: { + targets: transports + .map((t) => t.getTarget()) + .filter((target): target is TransportTargetOptions => target !== null), + }, + }); + + return { instance, transports }; +} + +let _instance: pino.Logger | null = null; +let _transports: LogTransportProvider[] = []; + +export function initLogger(config: AppConfig): pino.Logger { + const result = buildLogger(config); + _instance = result.instance; + _transports = result.transports; + return _instance; +} + +function getInstance(): pino.Logger { + if (_instance === null) { + return initLogger(loadConfig()); + } + return _instance; +} + +// Export as a getter so consumers see the lazily-initialized instance. +export const logger: pino.Logger = new Proxy({} as pino.Logger, { + get(_, prop) { + const inst = getInstance(); + const value = (inst as unknown as Record<string | symbol, unknown>)[prop]; + return typeof value === "function" ? value.bind(inst) : value; + }, +}); + +export function logActiveTransports(log: pino.Logger): void { + for (const t of _transports) { + const msg = t.getStartupMessage(); + if (msg !== null) { + log.info(msg); + } + } +} + +export function createLogger(name: string) { + return logger.child({ name }); +} diff --git a/media-processor/src/middleware/auth.ts b/media-processor/src/middleware/auth.ts new file mode 100644 index 0000000000..72e1ca743b --- /dev/null +++ b/media-processor/src/middleware/auth.ts @@ -0,0 +1,27 @@ +import { timingSafeEqual } from "node:crypto"; +import type { Request, Response, NextFunction } from "express"; + +export function sharedKeyAuth(expectedKey: string | null) { + if (expectedKey === null) { + return (_req: Request, res: Response, _next: NextFunction): void => { + res.status(403).json({ type: "authorization", code: "forbidden", hint: "Shared key not configured" }); + }; + } + + return (req: Request, res: Response, next: NextFunction): void => { + const provided = req.headers["x-shared-key"]; + if (typeof provided !== "string") { + res.status(403).json({ type: "authorization", code: "forbidden" }); + return; + } + + const providedBuf = Buffer.from(provided); + const expectedBuf = Buffer.from(expectedKey); + + if (providedBuf.length === expectedBuf.length && timingSafeEqual(providedBuf, expectedBuf)) { + next(); + } else { + res.status(403).json({ type: "authorization", code: "forbidden" }); + } + }; +} diff --git a/media-processor/src/middleware/cleanup.ts b/media-processor/src/middleware/cleanup.ts new file mode 100644 index 0000000000..ec5796154a --- /dev/null +++ b/media-processor/src/middleware/cleanup.ts @@ -0,0 +1,25 @@ +import { rm } from "node:fs/promises"; +import type { Request, Response, NextFunction } from "express"; +import { createLogger } from "../logger.js"; + +const logger = createLogger("cleanup"); + +export function cleanupMiddleware(req: Request, _res: Response, next: NextFunction): void { + let cleaned = false; + + _res.on("finish", cleanup); + _res.on("close", cleanup); + + async function cleanup() { + if (cleaned) return; + cleaned = true; + const file = req.file as (Express.Multer.File & { path?: string }) | undefined; + if (file?.path) { + await rm(file.path, { force: true }).catch((err) => { + logger.debug({ err, path: file.path }, "Failed to cleanup uploaded file"); + }); + } + } + + next(); +} diff --git a/media-processor/src/middleware/error-handler.ts b/media-processor/src/middleware/error-handler.ts new file mode 100644 index 0000000000..f7834c59fe --- /dev/null +++ b/media-processor/src/middleware/error-handler.ts @@ -0,0 +1,55 @@ +import type { Request, Response, NextFunction } from "express"; +import type { AppError } from "../types.js"; +import { createLogger } from "../logger.js"; +import multer from "multer"; + +const logger = createLogger("error-handler"); + +export class ProcessingError extends Error { + public readonly statusCode: number; + public readonly errorBody: AppError; + + constructor(statusCode: number, body: AppError) { + super(body.hint ?? body.code); + this.statusCode = statusCode; + this.errorBody = body; + } +} + +function releaseSlot(res: Response): void { + const releaseQueue = (res as any).locals?.releaseQueue; + if (releaseQueue) releaseQueue(); +} + +export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void { + if (res.headersSent) { + return; + } + + if (err instanceof ProcessingError) { + logger.warn({ err, statusCode: err.statusCode }, "Processing error"); + res.status(err.statusCode).json(err.errorBody); + releaseSlot(res); + return; + } + + if (err instanceof multer.MulterError) { + if (err.code === "LIMIT_FILE_SIZE") { + logger.warn({ err }, "Upload size limit exceeded"); + res.status(413).json({ + type: "restriction", + code: "payload-too-large", + }); + releaseSlot(res); + return; + } + } + + logger.error({ err }, "Unhandled error"); + res.status(500).json({ + type: "internal", + code: "processing-error", + hint: "Internal server error", + }); + releaseSlot(res); +} diff --git a/media-processor/src/middleware/logging.ts b/media-processor/src/middleware/logging.ts new file mode 100644 index 0000000000..8c7ecc6948 --- /dev/null +++ b/media-processor/src/middleware/logging.ts @@ -0,0 +1,21 @@ +import type { Request, Response, NextFunction } from "express"; +import { logger } from "../logger.js"; + +const OP_NAMES: Record<string, string> = { + "POST /api/image/info": "image/info", + "POST /api/image/thumbnail": "image/thumbnail", + "POST /api/font/convert": "font/convert", +}; + +export function loggingMiddleware(req: Request, res: Response, next: NextFunction): void { + const start = Date.now(); + res.on("finish", () => { + const path = req.originalUrl?.split("?")[0]; + const op = OP_NAMES[`${req.method} ${path}`]; + if (op) { + const meta = res.locals.opMeta ? `, ${res.locals.opMeta}` : ""; + logger.info(`op=${op}${meta}, status=${res.statusCode}, elapsed=${Date.now() - start}ms`); + } + }); + next(); +} diff --git a/media-processor/src/middleware/queue.ts b/media-processor/src/middleware/queue.ts new file mode 100644 index 0000000000..f6e6485548 --- /dev/null +++ b/media-processor/src/middleware/queue.ts @@ -0,0 +1,34 @@ +import type { Request, Response, NextFunction } from "express"; +import PQueue from "p-queue"; + +export function createQueueMiddleware(concurrency: number) { + const queue = new PQueue({ concurrency }); + + return function queueMiddleware(_req: Request, res: Response, next: NextFunction): void { + queue + .add( + () => + new Promise<void>((resolve) => { + if (res.writableEnded) { + resolve(); + return; + } + + let released = false; + const release = () => { + if (!released) { + released = true; + resolve(); + } + }; + + // Store releaseQueue callback on res.locals so route handlers and error handler can call it + (res as any).locals = (res as any).locals || {}; + (res as any).locals.releaseQueue = release; + + next(); + }) + ) + .catch((err) => next(err instanceof Error ? err : new Error("Request processing failed"))); + }; +} diff --git a/media-processor/src/middleware/timeout.ts b/media-processor/src/middleware/timeout.ts new file mode 100644 index 0000000000..3b261a4082 --- /dev/null +++ b/media-processor/src/middleware/timeout.ts @@ -0,0 +1,35 @@ +import type { Request, Response, NextFunction } from "express"; + +export function timeoutMiddleware(timeout: number) { + return (req: Request, res: Response, next: NextFunction): void => { + // Create AbortController for request cancellation + const abortController = new AbortController(); + (req as any).abortController = abortController; + + const timer = setTimeout(() => { + if (!res.headersSent) { + res.status(504).json({ + type: "internal", + code: "processing-timeout", + hint: "Request timed out", + }); + // Abort the signal to cancel ongoing processing + abortController.abort(); + res.on("finish", () => req.destroy()); + } + }, timeout); + + // Clear timer on finish (successful completion) + res.on("finish", () => clearTimeout(timer)); + + // Clear timer and abort signal on close (client disconnect) + res.on("close", () => { + clearTimeout(timer); + if (!abortController.signal.aborted) { + abortController.abort(); + } + }); + + next(); + }; +} diff --git a/media-processor/src/routes/font.ts b/media-processor/src/routes/font.ts new file mode 100644 index 0000000000..885b6192c3 --- /dev/null +++ b/media-processor/src/routes/font.ts @@ -0,0 +1,55 @@ +import { Router, type IRouter, type Request, type Response, type NextFunction } from "express"; +import { getUpload, getFileInput } from "../upload.js"; +import { convertFont } from "../services/font.js"; +import { throwValidation } from "../services/errors.js"; +import { cleanupMiddleware } from "../middleware/cleanup.js"; + +const VALID_TARGET_MTYPES = new Set(["font/ttf", "font/otf", "font/woff"]); +const VALID_SOURCE_MTYPES = new Set(["font/ttf", "font/otf", "font/woff", "font/woff2"]); + +export function createFontRoutes(): IRouter { + const router: IRouter = Router(); + const upload = getUpload(); + + router.post( + "/convert", + upload.single("file"), + cleanupMiddleware, + async (req: Request, res: Response, next: NextFunction) => { + const releaseQueue = (res as any).locals?.releaseQueue; + const signal = (req as any).abortController?.signal; + try { + if (!req.file) { + throwValidation("invalid-font", "No file uploaded"); + } + + const input = getFileInput(req.file!); + const sourceMtype = req.file!.mimetype; + if (!VALID_SOURCE_MTYPES.has(sourceMtype)) { + throwValidation("invalid-font", `Unrecognized font mime-type: ${sourceMtype}`); + } + + const targetMtype = req.query["target-type"] as string; + if (!targetMtype || !VALID_TARGET_MTYPES.has(targetMtype)) { + throwValidation("invalid-font", `Invalid target-type. Must be one of: font/ttf, font/otf, font/woff`); + } + + res.locals.opMeta = `src=${sourceMtype}, dest=${targetMtype}`; + const result = await convertFont(input, sourceMtype, targetMtype, signal); + + if (!result) { + throwValidation("invalid-font", `Conversion from ${sourceMtype} to ${targetMtype} is not supported`); + } + + res.setHeader("Content-Type", targetMtype); + res.send(result); + } catch (err) { + next(err); + } finally { + if (releaseQueue) releaseQueue(); + } + } + ); + + return router; +} diff --git a/media-processor/src/routes/health.ts b/media-processor/src/routes/health.ts new file mode 100644 index 0000000000..e598130a69 --- /dev/null +++ b/media-processor/src/routes/health.ts @@ -0,0 +1,5 @@ +import type { Request, Response } from "express"; + +export function healthRoutes(_req: Request, res: Response): void { + res.json({ status: "ok" }); +} diff --git a/media-processor/src/routes/image.ts b/media-processor/src/routes/image.ts new file mode 100644 index 0000000000..ac28c55b4d --- /dev/null +++ b/media-processor/src/routes/image.ts @@ -0,0 +1,95 @@ +import { Router, type IRouter, type Request, type Response, type NextFunction } from "express"; +import { getUpload, getFileInput } from "../upload.js"; +import { getImageInfo, generateThumbnail } from "../services/image.js"; +import { throwValidation } from "../services/errors.js"; +import { cleanupMiddleware } from "../middleware/cleanup.js"; +import type { ThumbnailParams } from "../types.js"; + +export function parseQuality(value: string | undefined, defaultValue = 85): number { + if (value === undefined) return defaultValue; + const parsed = parseInt(value, 10); + if (isNaN(parsed)) return defaultValue; + return Math.min(100, Math.max(1, parsed)); +} + +export function createImageRoutes(): IRouter { + const router: IRouter = Router(); + const upload = getUpload(); + + router.post( + "/info", + upload.single("file"), + cleanupMiddleware, + async (req: Request, res: Response, next: NextFunction) => { + const releaseQueue = (res as any).locals?.releaseQueue; + const signal = (req as any).abortController?.signal; + try { + if (!req.file) { + throwValidation("invalid-image", "No file uploaded"); + } + + const input = getFileInput(req.file!); + const info = await getImageInfo(input, req.file!.size, signal); + res.locals.opMeta = `mtype=${info.mtype}, size=${info.width}x${info.height}`; + res.json(info); + } catch (err) { + next(err); + } finally { + if (releaseQueue) releaseQueue(); + } + } + ); + + router.post( + "/thumbnail", + upload.single("file"), + cleanupMiddleware, + async (req: Request, res: Response, next: NextFunction) => { + const releaseQueue = (res as any).locals?.releaseQueue; + const signal = (req as any).abortController?.signal; + try { + if (!req.file) { + throwValidation("invalid-image", "No file uploaded"); + } + + const input = getFileInput(req.file!); + const width = parseInt(req.query.width as string, 10); + const height = parseInt(req.query.height as string, 10); + const quality = parseQuality(req.query.quality as string); + const format = (req.query.format as string) || "jpeg"; + const mode = (req.query.mode as string) || "fit"; + + if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0) { + throwValidation("invalid-image", "width and height must be positive integers"); + } + + if (!["jpeg", "webp", "png"].includes(format)) { + throwValidation("invalid-image", `Unsupported format: ${format}`); + } + + if (!["fit", "crop"].includes(mode)) { + throwValidation("invalid-image", `Unsupported mode: ${mode}`); + } + + const params: ThumbnailParams = { + width, + height, + quality, + format: format as "jpeg" | "webp" | "png", + mode: mode as "fit" | "crop", + }; + + res.locals.opMeta = `size=${width}x${height}, fmt=${format}, mode=${mode}, q=${params.quality}`; + const { data, mtype } = await generateThumbnail(input, params, signal); + res.setHeader("Content-Type", mtype); + res.send(data); + } catch (err) { + next(err); + } finally { + if (releaseQueue) releaseQueue(); + } + } + ); + + return router; +} diff --git a/media-processor/src/services/errors.ts b/media-processor/src/services/errors.ts new file mode 100644 index 0000000000..7afdf93e37 --- /dev/null +++ b/media-processor/src/services/errors.ts @@ -0,0 +1,26 @@ +import { ProcessingError } from "../middleware/error-handler.js"; +import type { AppError } from "../types.js"; + +export function throwValidation(code: string, hint?: string): never { + throw new ProcessingError(400, { + type: "validation", + code, + hint, + } satisfies AppError); +} + +export function throwRestriction(code: string, hint?: string): never { + throw new ProcessingError(413, { + type: "restriction", + code, + hint, + } satisfies AppError); +} + +export function throwProcessing(code: string, hint?: string): never { + throw new ProcessingError(503, { + type: "internal", + code, + hint, + } satisfies AppError); +} diff --git a/media-processor/src/services/font.ts b/media-processor/src/services/font.ts new file mode 100644 index 0000000000..16e4a0a091 --- /dev/null +++ b/media-processor/src/services/font.ts @@ -0,0 +1,313 @@ +import { execFile } from "node:child_process"; +import { writeFile, readFile, mkdtemp, rm, copyFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { throwValidation, throwProcessing } from "./errors.js"; +import { createLogger } from "../logger.js"; +import type { FileInput } from "../types.js"; + +const logger = createLogger("font"); + +let fontProcessMem = 512; +let fontProcessCpuTime = 30; +let fontTimeout = 120000; + +export function configureFontLimits(opts: { mem: number; cpuTime: number; timeout: number }): void { + fontProcessMem = opts.mem; + fontProcessCpuTime = opts.cpuTime; + fontTimeout = opts.timeout; +} + +export function execCommand( + cmd: string, + args: string[], + timeout?: number, + options?: { encoding?: BufferEncoding | "buffer"; signal?: AbortSignal } +): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> { + const effectiveTimeout = timeout ?? fontTimeout; + const encoding = options?.encoding ?? "utf8"; + + // Use prlimit on Linux for memory + CPU resource limits + // Matches backend's prlimit-based font processing protection + const isLinux = process.platform === "linux"; + let finalCmd = cmd; + let finalArgs = args; + + if (isLinux && cmd !== "prlimit") { + // Wrap with prlimit: address space ceiling + CPU time limit + const prlimitArgs = [ + `--as=${fontProcessMem * 1024 * 1024}`, // address space (memory) + `--cpu=${fontProcessCpuTime}`, // CPU seconds + "--", + cmd, + ...args, + ]; + finalCmd = "prlimit"; + finalArgs = prlimitArgs; + } + + return new Promise((resolve, reject) => { + execFile( + finalCmd, + finalArgs, + { + timeout: effectiveTimeout, + encoding: encoding === "buffer" ? null : encoding, + signal: options?.signal, + }, + (err, stdout, stderr) => { + if (err) { + const error = new Error(`Command failed: ${finalCmd} ${finalArgs.join(" ")}\n${stderr}`); + if (err.killed) (error as any).killed = err.killed; + if (err.signal) (error as any).signal = err.signal; + if (err.code !== null && err.code !== undefined) (error as any).code = err.code; + reject(error); + } else { + resolve({ stdout, stderr }); + } + } + ); + }); +} + +async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> { + const dir = await mkdtemp(join(tmpdir(), "penpot.font.")); + try { + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }).catch(() => {}); + } +} + +async function withTempInput<T>( + ext: string, + input: FileInput, + fn: (dir: string, inputPath: string) => Promise<T> +): Promise<T> { + return withTempDir(async (dir) => { + const inputPath = join(dir, `input${ext}`); + if (typeof input === "string") { + await copyFile(input, inputPath); + } else { + await writeFile(inputPath, input); + } + return fn(dir, inputPath); + }); +} + +async function fontConvert( + inputExt: string, + outputExt: string, + input: FileInput, + signal?: AbortSignal +): Promise<Buffer | null> { + return withTempDir(async (dir) => { + let inputPath: string; + if (typeof input === "string") { + inputPath = input; // Use path directly — avoids reading file into heap + } else { + inputPath = join(dir, `input${inputExt}`); + await writeFile(inputPath, input); // Write buffer to temp file + } + + // Ensure input path is from tmpdir to prevent injection + if (!inputPath.startsWith(tmpdir())) { + throw new Error("Font processing denied: input path is outside expected directory"); + } + + const outputPath = join(dir, `input${outputExt}`); + try { + // Escape single quotes for FontForge's string parser (not shell). + // execFile passes args as an array — no shell injection vector. + // FontForge's own lexer uses doubled single quotes for escaping. + const escInput = inputPath.replace(/'/g, "''"); + const escOutput = outputPath.replace(/'/g, "''"); + await execCommand("fontforge", ["-lang=ff", "-c", `Open('${escInput}'); Generate('${escOutput}')`], undefined, { + signal, + }); + return await readFile(outputPath); + } catch (err: unknown) { + const error = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string }; + // Detect resource limit kills from prlimit (SIGKILL = OOM, SIGXCPU = CPU time exceeded) + if (error.killed || error.signal === "SIGKILL" || error.signal === "SIGXCPU") { + logger.warn({ err, inputExt, outputExt }, "FontForge killed by resource limits"); + throwProcessing("resource-limit-exceeded", "Font processing exceeded resource limits"); + } + logger.warn({ err, inputExt, outputExt }, "FontForge conversion failed"); + return null; + } + }); +} + +async function ttfToOtf(input: FileInput, signal?: AbortSignal): Promise<Buffer | null> { + return fontConvert(".ttf", ".otf", input, signal); +} + +async function otfToTtf(input: FileInput, signal?: AbortSignal): Promise<Buffer | null> { + return fontConvert(".otf", ".ttf", input, signal); +} + +async function sfntToWoff(input: FileInput, ext: string = ".ttf", signal?: AbortSignal): Promise<Buffer | null> { + return withTempInput(ext, input, async (dir, inputPath) => { + try { + await execCommand("sfnt2woff", [inputPath], undefined, { signal }); + const output = join(dir, "input.woff"); + return await readFile(output); + } catch (err: unknown) { + const error = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string }; + if (error.killed || error.signal === "SIGKILL" || error.signal === "SIGXCPU") { + logger.warn({ err }, "sfnt2woff killed by resource limits"); + throwProcessing("resource-limit-exceeded", "Font processing exceeded resource limits"); + } + logger.warn({ err }, "sfnt2woff conversion failed"); + return null; + } + }); +} + +async function woffToSfnt(input: FileInput, signal?: AbortSignal): Promise<Buffer | null> { + return withTempInput(".woff", input, async (_dir, inputPath) => { + try { + const { stdout } = await execCommand("woff2sfnt", [inputPath], undefined, { encoding: "buffer", signal }); + return stdout as Buffer; + } catch (err: unknown) { + const error = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string }; + if (error.killed || error.signal === "SIGKILL" || error.signal === "SIGXCPU") { + logger.warn({ err }, "woff2sfnt killed by resource limits"); + throwProcessing("resource-limit-exceeded", "Font processing exceeded resource limits"); + } + logger.warn({ err }, "woff2sfnt conversion failed"); + return null; + } + }); +} + +async function woff2ToSfnt(input: FileInput, signal?: AbortSignal): Promise<Buffer | null> { + return withTempInput(".woff2", input, async (dir, inputPath) => { + const output = join(dir, "input.ttf"); + try { + await execCommand("woff2_decompress", [inputPath], undefined, { signal }); + return await readFile(output); + } catch (err: unknown) { + const error = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string }; + if (error.killed || error.signal === "SIGKILL" || error.signal === "SIGXCPU") { + logger.warn({ err }, "woff2_decompress killed by resource limits"); + throwProcessing("resource-limit-exceeded", "Font processing exceeded resource limits"); + } + logger.warn({ err }, "woff2_decompress failed"); + return null; + } + }); +} + +function getSfntType(data: Buffer): "ttf" | "otf" { + const magic = data.subarray(0, 4).toString("hex"); + switch (magic) { + case "4f54544f": + return "otf"; + case "00010000": + return "ttf"; + default: + throwValidation("invalid-font", "Unrecognized font format"); + } +} + +async function convertFromSfnt(sfnt: Buffer, targetType: string, signal?: AbortSignal): Promise<Buffer | null> { + if (targetType === "ttf") { + const stype = getSfntType(sfnt); + if (stype === "ttf") return sfnt; + return otfToTtf(sfnt, signal); + } + if (targetType === "otf") { + const stype = getSfntType(sfnt); + if (stype === "otf") return sfnt; + return ttfToOtf(sfnt, signal); + } + if (targetType === "woff") { + return sfntToWoff(sfnt, ".ttf", signal); + } + return null; +} + +function validateFontSignature(data: Buffer, expectedType: string): void { + if (data.length < 4) { + throwValidation("invalid-font", "Font data too short"); + } + + const magic = data.subarray(0, 4).toString("hex"); + + switch (expectedType) { + case "ttf": + if (magic !== "00010000") { + throwValidation("invalid-font", "Invalid TTF signature"); + } + break; + case "otf": + if (magic !== "4f54544f") { + throwValidation("invalid-font", "Invalid OTF signature"); + } + break; + case "woff": + if (magic !== "774f4646") { + throwValidation("invalid-font", "Invalid WOFF signature"); + } + break; + case "woff2": + if (magic !== "774f4632") { + throwValidation("invalid-font", "Invalid WOFF2 signature"); + } + break; + } +} + +export async function convertFont( + input: FileInput, + sourceMtype: string, + targetMtype: string, + signal?: AbortSignal +): Promise<Buffer | null> { + const sourceType = sourceMtype.replace("font/", ""); + const targetType = targetMtype.replace("font/", ""); + + // Same type: validate signature and return data as-is + if (sourceType === targetType) { + let data: Buffer; + if (typeof input === "string") { + data = await readFile(input); + } else { + data = input; + } + validateFontSignature(data, sourceType); + return data; + } + + // Source is TTF + if (sourceType === "ttf") { + if (targetType === "otf") return ttfToOtf(input, signal); + if (targetType === "woff") return sfntToWoff(input, ".ttf", signal); + return null; + } + + // Source is OTF + if (sourceType === "otf") { + if (targetType === "ttf") return otfToTtf(input, signal); + if (targetType === "woff") return sfntToWoff(input, ".otf", signal); + return null; + } + + // Source is WOFF: extract sfnt first, then convert + if (sourceType === "woff") { + const sfnt = await woffToSfnt(input, signal); + if (!sfnt) { + throwValidation("invalid-font", "Could not extract SFNT from WOFF"); + } + return convertFromSfnt(sfnt, targetType, signal); + } + + // Source is WOFF2: decompress to sfnt, then convert + const sfnt = await woff2ToSfnt(input, signal); + if (!sfnt) { + throwValidation("invalid-font", "Could not decompress WOFF2"); + } + return convertFromSfnt(sfnt, targetType, signal); +} diff --git a/media-processor/src/services/image.ts b/media-processor/src/services/image.ts new file mode 100644 index 0000000000..302f41ddf5 --- /dev/null +++ b/media-processor/src/services/image.ts @@ -0,0 +1,204 @@ +import sharp from "sharp"; +import type { FileInput, ImageInfo, ThumbnailParams } from "../types.js"; +import { throwValidation, throwRestriction } from "./errors.js"; +import { createLogger } from "../logger.js"; + +const logger = createLogger("image"); + +const SUPPORTED_MIMES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); + +function orientationSwapDimensions( + width: number, + height: number, + orientation: number +): { width: number; height: number } { + if (orientation === 6 || orientation === 8) { + return { width: height, height: width }; + } + return { width, height }; +} + +let imageMaxPixels = 128_000_000; +let imageMaxWidth = 16384; +let imageMaxHeight = 16384; + +export function configureImageLimits(opts: { maxPixels: number; maxWidth: number; maxHeight: number }): void { + imageMaxPixels = opts.maxPixels; + imageMaxWidth = opts.maxWidth; + imageMaxHeight = opts.maxHeight; +} + +function validateImageDimensions(width: number, height: number): void { + if (width > imageMaxWidth || height > imageMaxHeight) { + throwRestriction( + "image-dimensions-exceeded", + `Image dimensions ${width}x${height} exceed maximum ${imageMaxWidth}x${imageMaxHeight}` + ); + } + const pixels = width * height; + if (pixels > imageMaxPixels) { + throwRestriction("image-pixel-count-exceeded", `Image pixel count ${pixels} exceeds maximum ${imageMaxPixels}`); + } +} + +function validateOutputDimensions(width: number, height: number): void { + if (width > imageMaxWidth || height > imageMaxHeight) { + throwRestriction( + "output-dimensions-exceeded", + `Requested output dimensions ${width}x${height} exceed maximum ${imageMaxWidth}x${imageMaxHeight}` + ); + } + const pixels = width * height; + if (pixels > imageMaxPixels) { + throwRestriction( + "output-pixel-count-exceeded", + `Requested output pixel count ${pixels} exceeds maximum ${imageMaxPixels}` + ); + } +} + +export async function getImageInfo(input: FileInput, size: number, signal?: AbortSignal): Promise<ImageInfo> { + if (signal?.aborted) { + throw new Error("Request cancelled"); + } + + let metadata; + try { + metadata = await sharp(input).metadata(); + } catch (err) { + throwValidation("invalid-image", `Failed to decode image: ${(err as Error).message}`); + } + + if (!metadata.width || !metadata.height) { + throwValidation("invalid-image", "Could not read image dimensions"); + } + + const mtype = metadata.format ? `image/${metadata.format}` : undefined; + if (!mtype || !SUPPORTED_MIMES.has(mtype)) { + throwValidation("invalid-image", `Unsupported image format: ${metadata.format}`); + } + + const orientation = metadata.orientation ?? 1; + const { width, height } = orientationSwapDimensions(metadata.width!, metadata.height!, orientation); + + validateImageDimensions(width, height); + + logger.debug({ width, height, mtype: mtype!, size }, "Image info extracted"); + + return { + width, + height, + mtype: mtype!, + size, + orientation, + }; +} + +const FORMAT_MIMES: Record<string, string> = { + jpeg: "image/jpeg", + webp: "image/webp", + png: "image/png", +}; + +export async function generateThumbnail( + input: FileInput, + params: ThumbnailParams, + signal?: AbortSignal +): Promise<{ data: Buffer; mtype: string }> { + // Check if request was cancelled before starting + if (signal?.aborted) { + throw new Error("Request cancelled"); + } + + // Pre-validate source image dimensions using the same sharp instance + // that will be used for the resize pipeline. Sharp reads metadata + // (dimensions, orientation) from the image header without fully decoding + // the pixel data, then reuses the instance for the resize operations. + const source = sharp(input); + let srcMeta; + try { + srcMeta = await source.metadata(); + } catch (err) { + throwValidation("invalid-image", `Failed to decode image: ${(err as Error).message}`); + } + + // Check again after metadata read + if (signal?.aborted) { + throw new Error("Request cancelled"); + } + + if (srcMeta.width == null || srcMeta.height == null) { + throwValidation("invalid-image", "Could not read source image dimensions"); + } + + // Validate source image format + if (srcMeta.format && !SUPPORTED_MIMES.has(`image/${srcMeta.format}`)) { + throwValidation("unsupported-image-format", `Unsupported image format: ${srcMeta.format}`); + } + + const orientation = srcMeta.orientation ?? 1; + const { width: displayWidth, height: displayHeight } = orientationSwapDimensions( + srcMeta.width, + srcMeta.height, + orientation + ); + validateImageDimensions(displayWidth, displayHeight); + + // Validate requested output dimensions (important for crop mode which can enlarge) + validateOutputDimensions(params.width, params.height); + + logger.debug( + { width: params.width, height: params.height, format: params.format, mode: params.mode }, + "Generating thumbnail" + ); + + let pipeline = source.rotate(); + + // Only flatten for JPEG output (which doesn't support transparency). + // PNG and WebP support alpha, so preserve it. + if (params.format === "jpeg") { + pipeline = pipeline.flatten({ background: { r: 255, g: 255, b: 255 } }); + } + + if (params.mode === "fit") { + pipeline = pipeline.resize(params.width, params.height, { + fit: "inside", + withoutEnlargement: true, + }); + } else { + pipeline = pipeline.resize(params.width, params.height, { + fit: "cover", + position: "center", + }); + } + + switch (params.format) { + case "jpeg": + pipeline = pipeline.jpeg({ quality: params.quality }); + break; + case "webp": + pipeline = pipeline.webp({ quality: params.quality }); + break; + case "png": + pipeline = pipeline.png(); + break; + } + + let data: Buffer; + try { + // Sharp 0.35.3 does not support cancellation of native libvips operations. + // toBuffer() only accepts { resolveWithObject: boolean }, no AbortSignal. + // We hold the queue slot until Sharp completes fully, then check signal + // to throw if the request was cancelled during processing. This prevents + // concurrency limit violations and handles timeouts gracefully. + data = await pipeline.toBuffer(); + } catch (err) { + throwValidation("invalid-image", `Failed to process image: ${(err as Error).message}`); + } + + if (signal?.aborted) { + throw new Error("Request cancelled"); + } + + return { data, mtype: FORMAT_MIMES[params.format] }; +} diff --git a/media-processor/src/types.ts b/media-processor/src/types.ts new file mode 100644 index 0000000000..22c702f5f7 --- /dev/null +++ b/media-processor/src/types.ts @@ -0,0 +1,44 @@ +export type FileInput = Buffer | string; + +export interface AppConfig { + port: number; + host: string; + maxConcurrentRequests: number; + requestTimeout: number; + maxFileSize: number; + memoryThreshold: number; + imageMaxPixels: number; + imageMaxWidth: number; + imageMaxHeight: number; + fontProcessMem: number; + fontProcessCpuTime: number; + fontTimeout: number; + sharedKey: string | null; + logLevel: string; + lokiUri: string | null; + lokiJob: string; + lokiEnvironment: string | null; + lokiInstance: string | null; +} + +export interface ImageInfo { + width: number; + height: number; + mtype: string; + size: number; + orientation: number; +} + +export interface ThumbnailParams { + width: number; + height: number; + quality: number; + format: "jpeg" | "webp" | "png"; + mode: "fit" | "crop"; +} + +export interface AppError { + type: "validation" | "restriction" | "internal"; + code: string; + hint?: string; +} diff --git a/media-processor/src/upload-storage.ts b/media-processor/src/upload-storage.ts new file mode 100644 index 0000000000..1677e3efe7 --- /dev/null +++ b/media-processor/src/upload-storage.ts @@ -0,0 +1,89 @@ +import multer from "multer"; +import { mkdtemp, rm } from "node:fs/promises"; +import { createWriteStream } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomBytes } from "node:crypto"; +import type { Request } from "express"; + +interface HybridStorageOptions { + memoryThreshold: number; +} + +interface FileInfo { + destination: string; + filename: string; + path: string; + size: number; +} + +function getContentLength(req: Request): number { + const cl = req.headers["content-length"]; + if (!cl) return -1; + const parsed = parseInt(cl, 10); + return isNaN(parsed) ? -1 : parsed; +} + +export function createHybridStorage(opts: HybridStorageOptions): multer.StorageEngine { + const memoryStorage = multer.memoryStorage(); + + let tempDirPromise: Promise<string> | null = null; + + async function ensureTempDir(): Promise<string> { + if (!tempDirPromise) { + tempDirPromise = mkdtemp(join(tmpdir(), "penpot.upload.")); + } + return tempDirPromise; + } + + return { + _handleFile(req: Request, file: Express.Multer.File, cb: (error?: any, info?: Partial<FileInfo>) => void): void { + const contentLength = getContentLength(req); + const useDisk = contentLength < 0 || contentLength >= opts.memoryThreshold; + + if (!useDisk) { + memoryStorage._handleFile(req, file, cb); + return; + } + + ensureTempDir() + .then((dir) => { + const filename = `${randomBytes(16).toString("hex")}${getExt(file.originalname)}`; + const filepath = join(dir, filename); + + const ws = createWriteStream(filepath); + + file.stream.pipe(ws); + + ws.on("error", (err: Error) => { + cb(err); + }); + + ws.on("finish", () => { + cb(null, { + destination: dir, + filename, + path: filepath, + size: ws.bytesWritten, + }); + }); + }) + .catch(cb); + }, + + _removeFile(req: Request, file: Express.Multer.File & { path?: string }, cb: (error: Error | null) => void): void { + if (file.path) { + rm(file.path, { force: true }) + .then(() => cb(null)) + .catch(() => cb(null)); + } else { + cb(null); + } + }, + }; +} + +function getExt(filename: string): string { + const dot = filename.lastIndexOf("."); + return dot >= 0 ? filename.substring(dot) : ""; +} diff --git a/media-processor/src/upload.ts b/media-processor/src/upload.ts new file mode 100644 index 0000000000..3ba22fb899 --- /dev/null +++ b/media-processor/src/upload.ts @@ -0,0 +1,50 @@ +import multer from "multer"; +import { readFile } from "node:fs/promises"; +import { createHybridStorage } from "./upload-storage.js"; +import type { Request } from "express"; +import type { FileInput } from "./types.js"; + +let _upload: multer.Multer | null = null; + +// Hybrid storage: small uploads (< memoryThreshold) buffered in RAM for speed; +// large uploads streamed to disk to avoid heap pressure. +// Default threshold is 10MB. Disk files are cleaned up after response finishes. +export function configureUploadLimits(opts: { maxFileSize: number; memoryThreshold: number }): void { + const storage = createHybridStorage({ memoryThreshold: opts.memoryThreshold }); + + _upload = multer({ + storage, + limits: { fileSize: opts.maxFileSize }, + }); +} + +export function getUpload(): multer.Multer { + if (!_upload) { + throw new Error("Upload not configured — call configureUploadLimits first"); + } + return _upload; +} + +// Returns file input suitable for sharp and font processing. +// For disk-stored files, returns the file path (libvips uses mmap). +// For memory-stored files, returns the buffer. +export function getFileInput(file: Express.Multer.File): FileInput { + if (file.path) { + return file.path; + } + if (file.buffer) { + return file.buffer; + } + throw new Error("File has no buffer or path"); +} + +// Returns file contents as Buffer regardless of storage backend (memory or disk). +export async function getFileBuffer(file: Express.Multer.File): Promise<Buffer> { + if (file.buffer) { + return file.buffer; + } + if (file.path) { + return readFile(file.path); + } + throw new Error("File has no buffer or path"); +} diff --git a/media-processor/test/config.test.ts b/media-processor/test/config.test.ts new file mode 100644 index 0000000000..019cbd4947 --- /dev/null +++ b/media-processor/test/config.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { loadConfig } from "../src/config.js"; + +describe("loadConfig", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("uses defaults when env vars not set", () => { + const config = loadConfig(); + expect(config.port).toBe(6065); + expect(config.host).toBe("0.0.0.0"); + expect(config.maxConcurrentRequests).toBe(10); + expect(config.requestTimeout).toBe(180000); + expect(config.maxFileSize).toBe(367001600); + expect(config.memoryThreshold).toBe(10485760); + }); + + it("accepts valid config with all fields set", () => { + process.env.PENPOT_MEDIA_PROCESSOR_PORT = "8080"; + process.env.PENPOT_MEDIA_PROCESSOR_HOST = "127.0.0.1"; + process.env.PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS = "20"; + process.env.PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT = "30000"; + process.env.PENPOT_MEDIA_PROCESSOR_MAX_FILE_SIZE = "104857600"; + process.env.PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD = "5242880"; + process.env.PENPOT_MEDIA_PROCESSOR_SHARED_KEY = "test-key"; + + const config = loadConfig(); + expect(config.port).toBe(8080); + expect(config.host).toBe("127.0.0.1"); + expect(config.maxConcurrentRequests).toBe(20); + expect(config.requestTimeout).toBe(30000); + expect(config.maxFileSize).toBe(104857600); + expect(config.memoryThreshold).toBe(5242880); + expect(config.sharedKey).toBe("test-key"); + }); + + it("rejects concurrency=0", () => { + process.env.PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS = "0"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative concurrency", () => { + process.env.PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS = "-5"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects fractional concurrency", () => { + process.env.PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS = "2.5"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative timeout", () => { + process.env.PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT = "-1000"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects fractional timeout", () => { + process.env.PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT = "1000.5"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects fractional port", () => { + process.env.PENPOT_MEDIA_PROCESSOR_PORT = "8080.5"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative port", () => { + process.env.PENPOT_MEDIA_PROCESSOR_PORT = "-8080"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative max file size", () => { + process.env.PENPOT_MEDIA_PROCESSOR_MAX_FILE_SIZE = "-100"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative memory threshold", () => { + process.env.PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD = "-100"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative image max pixels", () => { + process.env.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_PIXELS = "-100"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects fractional image max width", () => { + process.env.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_WIDTH = "100.5"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects fractional image max height", () => { + process.env.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_HEIGHT = "100.5"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative font process mem", () => { + process.env.PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_MEM = "-512"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative font process cpu time", () => { + process.env.PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_CPU_TIME = "-30"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative font timeout", () => { + process.env.PENPOT_MEDIA_PROCESSOR_FONT_TIMEOUT = "-120000"; + expect(() => loadConfig()).toThrow(); + }); + + it("accepts concurrency=1 (minimum valid)", () => { + process.env.PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS = "1"; + const config = loadConfig(); + expect(config.maxConcurrentRequests).toBe(1); + }); + + it("accepts timeout=0 (edge case, might be valid for testing)", () => { + process.env.PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT = "0"; + const config = loadConfig(); + expect(config.requestTimeout).toBe(0); + }); +}); diff --git a/media-processor/test/fixtures/font-1.otf b/media-processor/test/fixtures/font-1.otf new file mode 100644 index 0000000000..9326ec7844 Binary files /dev/null and b/media-processor/test/fixtures/font-1.otf differ diff --git a/media-processor/test/fixtures/font-1.ttf b/media-processor/test/fixtures/font-1.ttf new file mode 100644 index 0000000000..cb2f335971 Binary files /dev/null and b/media-processor/test/fixtures/font-1.ttf differ diff --git a/media-processor/test/fixtures/font-1.woff b/media-processor/test/fixtures/font-1.woff new file mode 100644 index 0000000000..9607e1e194 Binary files /dev/null and b/media-processor/test/fixtures/font-1.woff differ diff --git a/media-processor/test/fixtures/font-1.woff2 b/media-processor/test/fixtures/font-1.woff2 new file mode 100644 index 0000000000..492d463d90 Binary files /dev/null and b/media-processor/test/fixtures/font-1.woff2 differ diff --git a/media-processor/test/font.test.ts b/media-processor/test/font.test.ts new file mode 100644 index 0000000000..cb0a462801 --- /dev/null +++ b/media-processor/test/font.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { readFile, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { convertFont, execCommand } from "../src/services/font.js"; +import { ProcessingError } from "../src/middleware/error-handler.js"; + +const FIXTURES = join(import.meta.dirname, "fixtures"); + +let ttfData: Buffer; +let otfData: Buffer; +let woffData: Buffer; +let woff2Data: Buffer; + +beforeAll(async () => { + [ttfData, otfData, woffData, woff2Data] = await Promise.all([ + readFile(join(FIXTURES, "font-1.ttf")), + readFile(join(FIXTURES, "font-1.otf")), + readFile(join(FIXTURES, "font-1.woff")), + readFile(join(FIXTURES, "font-1.woff2")), + ]); +}); + +describe("convertFont", () => { + describe("sourceType=ttf", () => { + it("ttf→ttf returns the input buffer unchanged", async () => { + const result = await convertFont(ttfData, "font/ttf", "font/ttf"); + expect(result).toBe(ttfData); + }); + + it("ttf→otf returns non-null Buffer", async () => { + const result = await convertFont(ttfData, "font/ttf", "font/otf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("ttf→woff returns non-null Buffer", async () => { + const result = await convertFont(ttfData, "font/ttf", "font/woff"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + }); + + describe("sourceType=otf", () => { + it("otf→otf returns the input buffer unchanged", async () => { + const result = await convertFont(otfData, "font/otf", "font/otf"); + expect(result).toBe(otfData); + }); + + it("otf→ttf returns non-null Buffer", async () => { + const result = await convertFont(otfData, "font/otf", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("otf→woff returns non-null Buffer", async () => { + const result = await convertFont(otfData, "font/otf", "font/woff"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + }); + + describe("sourceType=woff", () => { + it("woff→woff returns the input buffer unchanged", async () => { + const result = await convertFont(woffData, "font/woff", "font/woff"); + expect(result).toBe(woffData); + }); + + it("woff→ttf returns non-null Buffer", async () => { + const result = await convertFont(woffData, "font/woff", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("woff→otf returns Buffer or null (FontForge limitation)", async () => { + const result = await convertFont(woffData, "font/woff", "font/otf"); + // FontForge may fail to convert TTF-based WOFF to OTF for some fonts. + // The backend handles null gracefully (variant is just absent). + if (result !== null) { + expect(result).toBeInstanceOf(Buffer); + expect(result.length).toBeGreaterThan(0); + } + }); + }); + + describe("sourceType=woff2", () => { + it("woff2→ttf returns non-null Buffer", async () => { + const result = await convertFont(woff2Data, "font/woff2", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("woff2→otf returns non-null Buffer", async () => { + const result = await convertFont(woff2Data, "font/woff2", "font/otf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("woff2→woff returns non-null Buffer", async () => { + const result = await convertFont(woff2Data, "font/woff2", "font/woff"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + }); + + describe("invalid input", () => { + it("woff with garbage data throws ProcessingError", async () => { + const garbage = Buffer.from("not a font at all"); + await expect(convertFont(garbage, "font/woff", "font/ttf")).rejects.toThrow(ProcessingError); + }); + + it("woff2 with garbage data throws ProcessingError", async () => { + const garbage = Buffer.from("not a font at all"); + await expect(convertFont(garbage, "font/woff2", "font/ttf")).rejects.toThrow(ProcessingError); + }); + + it("sfnt with garbage data throws validation error", async () => { + const garbage = Buffer.from("not a font at all"); + try { + await convertFont(garbage, "font/woff", "font/ttf"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-font"); + } + }); + + it("ttf→ttf with invalid magic bytes throws validation error", async () => { + const invalidTtf = Buffer.from("00000000", "hex"); + try { + await convertFont(invalidTtf, "font/ttf", "font/ttf"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-font"); + } + }); + + it("otf→otf with invalid magic bytes throws validation error", async () => { + const invalidOtf = Buffer.from("00000000", "hex"); + try { + await convertFont(invalidOtf, "font/otf", "font/otf"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-font"); + } + }); + + it("woff→woff with invalid magic bytes throws validation error", async () => { + const invalidWoff = Buffer.from("00000000", "hex"); + try { + await convertFont(invalidWoff, "font/woff", "font/woff"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-font"); + } + }); + + it("woff2→woff2 with invalid magic bytes throws validation error", async () => { + const invalidWoff2 = Buffer.from("00000000", "hex"); + try { + await convertFont(invalidWoff2, "font/woff2", "font/woff2"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-font"); + } + }); + }); + + describe("data integrity", () => { + it("ttf→otf produces valid font buffer", async () => { + const result = await convertFont(ttfData, "font/ttf", "font/otf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("otf→ttf produces valid font buffer", async () => { + const result = await convertFont(otfData, "font/otf", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("woff→ttf produces valid SFNT with correct magic bytes", async () => { + const result = await convertFont(woffData, "font/woff", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + // SFNT magic: 00010000 (TTF) or 4f54544f (OTF/CFF) + const magic = result!.subarray(0, 4).toString("hex"); + expect(["00010000", "4f54544f"]).toContain(magic); + }); + + it("woff2→ttf produces valid font buffer", async () => { + const result = await convertFont(woff2Data, "font/woff2", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + }); + + describe("file path input", () => { + it("ttf→otf with file path returns non-null Buffer", async () => { + const tempPath = join(tmpdir(), `test-font-${Date.now()}.ttf`); + try { + await writeFile(tempPath, ttfData); + const result = await convertFont(tempPath, "font/ttf", "font/otf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + } finally { + await rm(tempPath, { force: true }); + } + }); + + it("ttf→ttf with file path returns file contents as Buffer", async () => { + const tempPath = join(tmpdir(), `test-font-${Date.now()}.ttf`); + try { + await writeFile(tempPath, ttfData); + const result = await convertFont(tempPath, "font/ttf", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBe(ttfData.length); + } finally { + await rm(tempPath, { force: true }); + } + }); + + it("woff→ttf with file path returns non-null Buffer", async () => { + const tempPath = join(tmpdir(), `test-font-${Date.now()}.woff`); + try { + await writeFile(tempPath, woffData); + const result = await convertFont(tempPath, "font/woff", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + } finally { + await rm(tempPath, { force: true }); + } + }); + + it("ttf→woff with file path returns non-null Buffer", async () => { + const tempPath = join(tmpdir(), `test-font-${Date.now()}.ttf`); + try { + await writeFile(tempPath, ttfData); + const result = await convertFont(tempPath, "font/ttf", "font/woff"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + } finally { + await rm(tempPath, { force: true }); + } + }); + + it("woff2→ttf with file path returns non-null Buffer", async () => { + const tempPath = join(tmpdir(), `test-font-${Date.now()}.woff2`); + try { + await writeFile(tempPath, woff2Data); + const result = await convertFont(tempPath, "font/woff2", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + } finally { + await rm(tempPath, { force: true }); + } + }); + }); + + describe("path validation", () => { + it("rejects string input path outside tmpdir", async () => { + const outsidePath = "/etc/passwd"; + try { + await convertFont(outsidePath, "font/ttf", "font/otf"); + expect.fail("should have thrown"); + } catch (err) { + const error = err as Error; + expect(error.message).toContain("Font processing denied: input path is outside expected directory"); + } + }); + }); +}); + +describe("execCommand", () => { + it("preserves killed and signal properties from child process errors", async () => { + try { + await execCommand("false", []); + expect.fail("should have thrown"); + } catch (err) { + const error = err as Error & { killed?: boolean; signal?: string; code?: number }; + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain("Command failed"); + } + }); + + it("preserves error properties when command is killed by signal", async () => { + try { + await execCommand("sh", ["-c", "kill -KILL $$"], 5000); + expect.fail("should have thrown"); + } catch (err) { + const error = err as Error & { killed?: boolean; signal?: string }; + expect(error).toBeInstanceOf(Error); + expect(error.signal).toBe("SIGKILL"); + } + }); +}); diff --git a/media-processor/test/image.test.ts b/media-processor/test/image.test.ts new file mode 100644 index 0000000000..b80d8ec3f5 --- /dev/null +++ b/media-processor/test/image.test.ts @@ -0,0 +1,909 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { getImageInfo, generateThumbnail, configureImageLimits } from "../src/services/image.js"; +import { parseQuality } from "../src/routes/image.js"; +import { ProcessingError } from "../src/middleware/error-handler.js"; +import sharp from "sharp"; +import { writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const DEFAULT_LIMITS = { + maxPixels: 128_000_000, + maxWidth: 16384, + maxHeight: 16384, +}; + +afterEach(() => { + configureImageLimits(DEFAULT_LIMITS); +}); + +describe("getImageInfo", () => { + it("returns correct info for a PNG", async () => { + const buffer = await sharp({ + create: { width: 100, height: 80, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 1 } }, + }) + .png() + .toBuffer(); + + const info = await getImageInfo(buffer, buffer.length); + expect(info.width).toBe(100); + expect(info.height).toBe(80); + expect(info.mtype).toBe("image/png"); + expect(info.size).toBe(buffer.length); + expect(info.orientation).toBe(1); + }); + + it("returns correct info for a JPEG", async () => { + const buffer = await sharp({ + create: { width: 200, height: 150, channels: 3, background: { r: 0, g: 255, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const info = await getImageInfo(buffer, buffer.length); + expect(info.width).toBe(200); + expect(info.height).toBe(150); + expect(info.mtype).toBe("image/jpeg"); + }); + + it("returns correct info for a WebP", async () => { + const buffer = await sharp({ + create: { width: 300, height: 250, channels: 3, background: { r: 0, g: 0, b: 255 } }, + }) + .webp() + .toBuffer(); + + const info = await getImageInfo(buffer, buffer.length); + expect(info.width).toBe(300); + expect(info.height).toBe(250); + expect(info.mtype).toBe("image/webp"); + }); + + it("throws on invalid image data", async () => { + const buffer = Buffer.from("not an image"); + await expect(getImageInfo(buffer, buffer.length)).rejects.toThrow(); + }); + + it("returns correct info for a GIF", async () => { + // sharp create doesn't support GIF directly, so create PNG then convert + const pngBuffer = await sharp({ + create: { width: 120, height: 90, channels: 3, background: { r: 200, g: 100, b: 50 } }, + }) + .png() + .toBuffer(); + + const gifBuffer = await sharp(pngBuffer).gif().toBuffer(); + const info = await getImageInfo(gifBuffer, gifBuffer.length); + expect(info.width).toBe(120); + expect(info.height).toBe(90); + expect(info.mtype).toBe("image/gif"); + }); + + it("returns size equal to buffer length", async () => { + const buffer = await sharp({ + create: { width: 50, height: 50, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const info = await getImageInfo(buffer, buffer.length); + expect(info.size).toBe(buffer.length); + }); + + it("defaults orientation to 1 when no EXIF data", async () => { + const buffer = await sharp({ + create: { width: 60, height: 40, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .png() + .toBuffer(); + + const info = await getImageInfo(buffer, buffer.length); + expect(info.orientation).toBe(1); + }); + + it("throws on garbage data (sharp unsupported format)", async () => { + const buffer = Buffer.alloc(100, 0xff); + await expect(getImageInfo(buffer, buffer.length)).rejects.toThrow(); + }); + + it("accepts file path input and returns correct info", async () => { + const buffer = await sharp({ + create: { width: 100, height: 80, channels: 3, background: { r: 0, g: 128, b: 255 } }, + }) + .jpeg() + .toBuffer(); + + const tempPath = join(tmpdir(), `test-image-${Date.now()}.jpg`); + try { + await writeFile(tempPath, buffer); + const info = await getImageInfo(tempPath, buffer.length); + expect(info.width).toBe(100); + expect(info.height).toBe(80); + expect(info.mtype).toBe("image/jpeg"); + expect(info.size).toBe(buffer.length); + } finally { + await rm(tempPath, { force: true }); + } + }); + + it("throws restriction when width exceeds limit", async () => { + configureImageLimits({ maxPixels: 128_000_000, maxWidth: 100, maxHeight: 16384 }); + const buffer = await sharp({ + create: { width: 200, height: 50, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + try { + await getImageInfo(buffer, buffer.length); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.code).toBe("image-dimensions-exceeded"); + } + }); + + it("throws restriction when height exceeds limit", async () => { + configureImageLimits({ maxPixels: 128_000_000, maxWidth: 16384, maxHeight: 100 }); + const buffer = await sharp({ + create: { width: 50, height: 200, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + try { + await getImageInfo(buffer, buffer.length); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.code).toBe("image-dimensions-exceeded"); + } + }); + + it("throws restriction when pixel count exceeds limit", async () => { + configureImageLimits({ maxPixels: 1000, maxWidth: 16384, maxHeight: 16384 }); + const buffer = await sharp({ + create: { width: 50, height: 50, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + try { + await getImageInfo(buffer, buffer.length); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.code).toBe("image-pixel-count-exceeded"); + } + }); + + it("passes when dimensions are exactly at the limit", async () => { + configureImageLimits({ maxPixels: 10000, maxWidth: 100, maxHeight: 100 }); + const buffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const info = await getImageInfo(buffer, buffer.length); + expect(info.width).toBe(100); + expect(info.height).toBe(100); + }); + + it("throws when pixel count is exactly 1 over limit", async () => { + configureImageLimits({ maxPixels: 9999, maxWidth: 16384, maxHeight: 16384 }); + const buffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + try { + await getImageInfo(buffer, buffer.length); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.errorBody.code).toBe("image-pixel-count-exceeded"); + } + }); + + it("throws when signal is aborted before processing", async () => { + const buffer = await sharp({ + create: { width: 100, height: 80, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .png() + .toBuffer(); + + const controller = new AbortController(); + controller.abort(); + + try { + await getImageInfo(buffer, buffer.length, controller.signal); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toBe("Request cancelled"); + } + }); +}); + +describe("generateThumbnail", () => { + const createImage = (w: number, h: number) => + sharp({ + create: { width: w, height: h, channels: 3, background: { r: 128, g: 128, b: 128 } }, + }) + .jpeg() + .toBuffer(); + + it("mode=fit produces thumbnail fitting within dimensions (no upscale)", async () => { + const buffer = await createImage(1000, 800); + const { data, mtype } = await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBeLessThanOrEqual(200); + expect(meta.height).toBeLessThanOrEqual(200); + expect(mtype).toBe("image/jpeg"); + }); + + it("mode=crop produces center-cropped thumbnail at exact dimensions", async () => { + const buffer = await createImage(1000, 800); + const { data, mtype } = await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "crop", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(200); + expect(meta.height).toBe(200); + expect(mtype).toBe("image/jpeg"); + }); + + it("supports webp output", async () => { + const buffer = await createImage(500, 400); + const { data, mtype } = await generateThumbnail(buffer, { + width: 100, + height: 100, + quality: 80, + format: "webp", + mode: "fit", + }); + expect(mtype).toBe("image/webp"); + const meta = await sharp(data).metadata(); + expect(meta.width).toBeLessThanOrEqual(100); + }); + + it("supports png output", async () => { + const buffer = await createImage(500, 400); + const { data, mtype } = await generateThumbnail(buffer, { + width: 100, + height: 100, + quality: 80, + format: "png", + mode: "fit", + }); + expect(mtype).toBe("image/png"); + }); + + it("fit mode does not upscale small source", async () => { + const buffer = await createImage(50, 40); + const { data } = await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(50); + expect(meta.height).toBe(40); + }); + + it("crop mode with non-square target", async () => { + const buffer = await createImage(1000, 500); + const { data, mtype } = await generateThumbnail(buffer, { + width: 200, + height: 100, + quality: 85, + format: "jpeg", + mode: "crop", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(200); + expect(meta.height).toBe(100); + expect(mtype).toBe("image/jpeg"); + }); + + it("png output with crop mode", async () => { + const buffer = await createImage(800, 600); + const { data, mtype } = await generateThumbnail(buffer, { + width: 150, + height: 150, + quality: 80, + format: "png", + mode: "crop", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(150); + expect(meta.height).toBe(150); + expect(mtype).toBe("image/png"); + }); + + it("webp output with crop mode", async () => { + const buffer = await createImage(800, 600); + const { data, mtype } = await generateThumbnail(buffer, { + width: 150, + height: 150, + quality: 80, + format: "webp", + mode: "crop", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(150); + expect(meta.height).toBe(150); + expect(mtype).toBe("image/webp"); + }); + + it("source at exact target dimensions (fit mode) returns same size", async () => { + const buffer = await createImage(200, 200); + const { data } = await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(200); + expect(meta.height).toBe(200); + }); + + it("source at exact target dimensions (crop mode) returns same size", async () => { + const buffer = await createImage(200, 200); + const { data } = await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "crop", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(200); + expect(meta.height).toBe(200); + }); + + it("very small source (1x1) with fit mode returns 1x1", async () => { + const buffer = await createImage(1, 1); + const { data } = await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(1); + expect(meta.height).toBe(1); + }); + + it("throws restriction when source exceeds dimension limits", async () => { + configureImageLimits({ maxPixels: 1000, maxWidth: 50, maxHeight: 50 }); + const buffer = await createImage(200, 200); + + try { + await generateThumbnail(buffer, { + width: 100, + height: 100, + quality: 85, + format: "jpeg", + mode: "fit", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + } + }); + + it("removes alpha channel from PNG source", async () => { + const pngBuffer = await sharp({ + create: { width: 100, height: 100, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 0.5 } }, + }) + .png() + .toBuffer(); + + const { data } = await generateThumbnail(pngBuffer, { + width: 50, + height: 50, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + // JPEG output should not have alpha + expect(meta.channels).toBe(3); + }); + + it("composites transparent PNG onto white background for JPEG", async () => { + // Create a fully transparent PNG — removeAlpha() would produce black, + // but the local ImageMagick path composites onto white. + const pngBuffer = await sharp({ + create: { width: 10, height: 10, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, + }) + .png() + .toBuffer(); + + const { data } = await generateThumbnail(pngBuffer, { + width: 10, + height: 10, + quality: 85, + format: "jpeg", + mode: "fit", + }); + + // Sample a pixel — should be white (255,255,255), not black (0,0,0) + const pixel = await sharp(data).raw().toBuffer(); + const r = pixel[0]; + const g = pixel[1]; + const b = pixel[2]; + expect(r).toBe(255); + expect(g).toBe(255); + expect(b).toBe(255); + }); + + it("GIF source works with thumbnail generation", async () => { + const pngBuffer = await sharp({ + create: { width: 200, height: 200, channels: 3, background: { r: 100, g: 100, b: 100 } }, + }) + .png() + .toBuffer(); + + const gifBuffer = await sharp(pngBuffer).gif().toBuffer(); + const { data, mtype } = await generateThumbnail(gifBuffer, { + width: 100, + height: 100, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBeLessThanOrEqual(100); + expect(meta.height).toBeLessThanOrEqual(100); + expect(mtype).toBe("image/jpeg"); + }); + + it("JPEG quality affects output file size", async () => { + // Create an image with actual detail (gradient) so quality matters + const width = 200; + const height = 200; + const channels = 3; + const rawBuffer = Buffer.alloc(width * height * channels); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * channels; + rawBuffer[idx] = (x * 255) / width; + rawBuffer[idx + 1] = (y * 255) / height; + rawBuffer[idx + 2] = ((x + y) * 255) / (width + height); + } + } + const buffer = await sharp(rawBuffer, { raw: { width, height, channels } }).jpeg().toBuffer(); + + const low = await generateThumbnail(buffer, { + width: 100, + height: 100, + quality: 10, + format: "jpeg", + mode: "fit", + }); + const high = await generateThumbnail(buffer, { + width: 100, + height: 100, + quality: 100, + format: "jpeg", + mode: "fit", + }); + expect(low.data.length).toBeLessThan(high.data.length); + }); + + it("accepts quality=1 (minimum valid quality)", async () => { + const buffer = await sharp({ + create: { width: 100, height: 80, channels: 3, background: { r: 128, g: 128, b: 128 } }, + }) + .jpeg() + .toBuffer(); + + const { data, mtype } = await generateThumbnail(buffer, { + width: 50, + height: 40, + quality: 1, + format: "jpeg", + mode: "fit", + }); + expect(data).toBeInstanceOf(Buffer); + expect(data.length).toBeGreaterThan(0); + expect(mtype).toBe("image/jpeg"); + }); + + it("accepts file path input for thumbnail generation", async () => { + const buffer = await sharp({ + create: { width: 100, height: 80, channels: 3, background: { r: 128, g: 128, b: 128 } }, + }) + .jpeg() + .toBuffer(); + + const tempPath = join(tmpdir(), `test-image-${Date.now()}.jpg`); + try { + await writeFile(tempPath, buffer); + const { data, mtype } = await generateThumbnail(tempPath, { + width: 50, + height: 40, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBeLessThanOrEqual(50); + expect(meta.height).toBeLessThanOrEqual(40); + expect(mtype).toBe("image/jpeg"); + } finally { + await rm(tempPath, { force: true }); + } + }); + + it("throws validation error for unsupported source format (TIFF)", async () => { + const pngBuffer = await sharp({ + create: { width: 100, height: 80, channels: 3, background: { r: 128, g: 128, b: 128 } }, + }) + .png() + .toBuffer(); + + const tiffBuffer = await sharp(pngBuffer).tiff().toBuffer(); + + try { + await generateThumbnail(tiffBuffer, { + width: 50, + height: 40, + quality: 85, + format: "jpeg", + mode: "fit", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("unsupported-image-format"); + } + }); + + it("throws invalid-image for corrupted image data", async () => { + // Create corrupted image by truncating valid image + const validBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + // Truncate to create corrupted data + const corruptedBuffer = validBuffer.subarray(0, Math.floor(validBuffer.length / 2)); + + try { + await getImageInfo(corruptedBuffer, corruptedBuffer.length); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-image"); + } + }); + + it("throws invalid-image for truncated image data in generateThumbnail", async () => { + const validBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + // Truncate to create corrupted data + const corruptedBuffer = validBuffer.subarray(0, Math.floor(validBuffer.length / 2)); + + try { + await generateThumbnail(corruptedBuffer, { + width: 50, + height: 50, + quality: 85, + format: "jpeg", + mode: "fit", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-image"); + } + }); + + it("preserves alpha channel in PNG output from transparent PNG", async () => { + // Create a transparent PNG with alpha < 1 + const transparentPng = await sharp({ + create: { width: 100, height: 100, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 0.5 } }, + }) + .png() + .toBuffer(); + + const { data, mtype } = await generateThumbnail(transparentPng, { + width: 50, + height: 50, + quality: 85, + format: "png", + mode: "fit", + }); + + expect(mtype).toBe("image/png"); + const meta = await sharp(data).metadata(); + // PNG should preserve alpha channel (4 channels) + expect(meta.channels).toBe(4); + }); + + it("preserves alpha channel in WebP output from transparent PNG", async () => { + const transparentPng = await sharp({ + create: { width: 100, height: 100, channels: 4, background: { r: 0, g: 255, b: 0, alpha: 0.5 } }, + }) + .png() + .toBuffer(); + + const { data, mtype } = await generateThumbnail(transparentPng, { + width: 50, + height: 50, + quality: 85, + format: "webp", + mode: "fit", + }); + + expect(mtype).toBe("image/webp"); + const meta = await sharp(data).metadata(); + // WebP should preserve alpha channel (4 channels) + expect(meta.channels).toBe(4); + }); + + it("preserves alpha channel in PNG output from transparent WebP", async () => { + // Create a transparent WebP + const transparentWebp = await sharp({ + create: { width: 100, height: 100, channels: 4, background: { r: 0, g: 0, b: 255, alpha: 0.5 } }, + }) + .webp() + .toBuffer(); + + const { data, mtype } = await generateThumbnail(transparentWebp, { + width: 50, + height: 50, + quality: 85, + format: "png", + mode: "fit", + }); + + expect(mtype).toBe("image/png"); + const meta = await sharp(data).metadata(); + // PNG should preserve alpha channel (4 channels) + expect(meta.channels).toBe(4); + }); + + it("throws restriction when requested width exceeds limit (crop mode)", async () => { + configureImageLimits({ maxPixels: 128_000_000, maxWidth: 100, maxHeight: 16384 }); + const buffer = await createImage(50, 50); + + try { + await generateThumbnail(buffer, { + width: 200, + height: 50, + quality: 85, + format: "jpeg", + mode: "crop", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.code).toBe("output-dimensions-exceeded"); + } + }); + + it("throws restriction when requested height exceeds limit (crop mode)", async () => { + configureImageLimits({ maxPixels: 128_000_000, maxWidth: 16384, maxHeight: 100 }); + const buffer = await createImage(50, 50); + + try { + await generateThumbnail(buffer, { + width: 50, + height: 200, + quality: 85, + format: "jpeg", + mode: "crop", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.code).toBe("output-dimensions-exceeded"); + } + }); + + it("throws restriction when requested pixel count exceeds limit (crop mode)", async () => { + configureImageLimits({ maxPixels: 10000, maxWidth: 16384, maxHeight: 16384 }); + const buffer = await createImage(50, 50); + + try { + await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "crop", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.code).toBe("output-pixel-count-exceeded"); + } + }); + + it("accepts requested dimensions at the limit (crop mode)", async () => { + configureImageLimits({ maxPixels: 10000, maxWidth: 100, maxHeight: 100 }); + const buffer = await createImage(50, 50); + + const { data, mtype } = await generateThumbnail(buffer, { + width: 100, + height: 100, + quality: 85, + format: "jpeg", + mode: "crop", + }); + + expect(mtype).toBe("image/jpeg"); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(100); + expect(meta.height).toBe(100); + }); + + it("throws when signal is aborted before processing", async () => { + const buffer = await createImage(100, 80); + + const controller = new AbortController(); + controller.abort(); + + try { + await generateThumbnail( + buffer, + { + width: 50, + height: 40, + quality: 85, + format: "jpeg", + mode: "fit", + }, + controller.signal + ); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toBe("Request cancelled"); + } + }); + + it("aborts during toBuffer when signal fires", async () => { + const buffer = await createImage(2000, 2000); + + const controller = new AbortController(); + const signal = controller.signal; + + const promise = generateThumbnail( + buffer, + { + width: 1000, + height: 1000, + quality: 85, + format: "jpeg", + mode: "fit", + }, + signal + ); + + setTimeout(() => controller.abort(), 10); + + try { + await promise; + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toBe("Request cancelled"); + } + }); + + it("waits for Sharp to complete before checking signal", async () => { + const buffer = await createImage(100, 80); + + const controller = new AbortController(); + const signal = controller.signal; + + const promise = generateThumbnail( + buffer, + { + width: 50, + height: 40, + quality: 85, + format: "jpeg", + mode: "fit", + }, + signal + ); + + controller.abort(); + + try { + await promise; + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toBe("Request cancelled"); + } + }); +}); + +describe("parseQuality", () => { + it("returns default when value is undefined", () => { + expect(parseQuality(undefined)).toBe(85); + }); + + it("returns default when value is empty string", () => { + expect(parseQuality("")).toBe(85); + }); + + it("returns default when value is not a number", () => { + expect(parseQuality("abc")).toBe(85); + }); + + it("returns parsed value when valid", () => { + expect(parseQuality("50")).toBe(50); + }); + + it("clamps quality=0 to 1 (minimum valid)", () => { + expect(parseQuality("0")).toBe(1); + }); + + it("preserves quality=1 (minimum valid)", () => { + expect(parseQuality("1")).toBe(1); + }); + + it("clamps quality=101 to 100 (maximum valid)", () => { + expect(parseQuality("101")).toBe(100); + }); + + it("preserves quality=100 (maximum valid)", () => { + expect(parseQuality("100")).toBe(100); + }); + + it("uses custom default value", () => { + expect(parseQuality(undefined, 75)).toBe(75); + expect(parseQuality("abc", 75)).toBe(75); + }); +}); diff --git a/media-processor/test/middleware.test.ts b/media-processor/test/middleware.test.ts new file mode 100644 index 0000000000..f7b33c74f3 --- /dev/null +++ b/media-processor/test/middleware.test.ts @@ -0,0 +1,681 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ProcessingError, errorHandler } from "../src/middleware/error-handler.js"; +import { sharedKeyAuth } from "../src/middleware/auth.js"; +import { timeoutMiddleware } from "../src/middleware/timeout.js"; +import { cleanupMiddleware } from "../src/middleware/cleanup.js"; +import { throwValidation, throwRestriction } from "../src/services/errors.js"; +import multer from "multer"; +import type { Request, Response, NextFunction } from "express"; +import { EventEmitter } from "node:events"; + +function mockRes() { + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + headersSent: false, + }; + return res as unknown as Response; +} + +function mockReq() { + return {} as Request; +} + +describe("ProcessingError", () => { + it("stores statusCode", () => { + const err = new ProcessingError(400, { + type: "validation", + code: "test-error", + }); + expect(err.statusCode).toBe(400); + }); + + it("stores errorBody", () => { + const body = { type: "validation" as const, code: "test-error", hint: "details" }; + const err = new ProcessingError(400, body); + expect(err.errorBody).toEqual(body); + }); + + it("message defaults to code when no hint", () => { + const err = new ProcessingError(400, { + type: "validation", + code: "test-error", + }); + expect(err.message).toBe("test-error"); + }); + + it("message uses hint when provided", () => { + const err = new ProcessingError(400, { + type: "validation", + code: "test-error", + hint: "something went wrong", + }); + expect(err.message).toBe("something went wrong"); + }); + + it("is an instance of Error", () => { + const err = new ProcessingError(500, { + type: "internal", + code: "internal-error", + }); + expect(err).toBeInstanceOf(Error); + }); +}); + +describe("throwValidation", () => { + it("throws ProcessingError with status 400", () => { + try { + throwValidation("bad-input", "invalid value"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.type).toBe("validation"); + expect(pe.errorBody.code).toBe("bad-input"); + expect(pe.errorBody.hint).toBe("invalid value"); + } + }); + + it("works without hint", () => { + try { + throwValidation("bad-input"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as ProcessingError; + expect(pe.errorBody.hint).toBeUndefined(); + } + }); +}); + +describe("throwRestriction", () => { + it("throws ProcessingError with status 413", () => { + try { + throwRestriction("too-large", "file exceeds limit"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.type).toBe("restriction"); + expect(pe.errorBody.code).toBe("too-large"); + expect(pe.errorBody.hint).toBe("file exceeds limit"); + } + }); + + it("works without hint", () => { + try { + throwRestriction("too-large"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as ProcessingError; + expect(pe.errorBody.hint).toBeUndefined(); + } + }); +}); + +describe("errorHandler", () => { + let res: ReturnType<typeof mockRes>; + let next: NextFunction; + + beforeEach(() => { + res = mockRes(); + next = vi.fn(); + }); + + it("handles ProcessingError (400 validation)", () => { + const err = new ProcessingError(400, { + type: "validation", + code: "bad-input", + hint: "invalid value", + }); + + errorHandler(err, mockReq(), res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + type: "validation", + code: "bad-input", + hint: "invalid value", + }); + }); + + it("handles ProcessingError (413 restriction)", () => { + const err = new ProcessingError(413, { + type: "restriction", + code: "payload-too-large", + }); + + errorHandler(err, mockReq(), res, next); + + expect(res.status).toHaveBeenCalledWith(413); + expect(res.json).toHaveBeenCalledWith({ + type: "restriction", + code: "payload-too-large", + }); + }); + + it("handles MulterError LIMIT_FILE_SIZE as 413", () => { + const err = new multer.MulterError("LIMIT_FILE_SIZE"); + errorHandler(err, mockReq(), res, next); + + expect(res.status).toHaveBeenCalledWith(413); + expect(res.json).toHaveBeenCalledWith({ + type: "restriction", + code: "payload-too-large", + }); + }); + + it("handles generic Error as 500", () => { + const err = new Error("something broke"); + + errorHandler(err, mockReq(), res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + type: "internal", + code: "processing-error", + hint: "Internal server error", + }); + }); + + it("handles Error with empty message", () => { + const err = new Error(""); + + errorHandler(err, mockReq(), res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + type: "internal", + code: "processing-error", + hint: "Internal server error", + }); + }); + + it("does not write response if headers already sent", () => { + const err = new ProcessingError(400, { + type: "validation", + code: "bad-input", + hint: "invalid value", + }); + + const resWithHeadersSent = { + ...res, + headersSent: true, + }; + + errorHandler(err, mockReq(), resWithHeadersSent, next); + + expect(resWithHeadersSent.status).not.toHaveBeenCalled(); + expect(resWithHeadersSent.json).not.toHaveBeenCalled(); + }); + + it("calls releaseQueue for ProcessingError", () => { + const releaseQueue = vi.fn(); + const resWithLocals = { ...res, locals: { releaseQueue } } as any; + const err = new ProcessingError(400, { type: "validation", code: "test" }); + + errorHandler(err, mockReq(), resWithLocals, next); + + expect(releaseQueue).toHaveBeenCalled(); + }); + + it("calls releaseQueue for MulterError LIMIT_FILE_SIZE", () => { + const releaseQueue = vi.fn(); + const resWithLocals = { ...res, locals: { releaseQueue } } as any; + const err = new multer.MulterError("LIMIT_FILE_SIZE"); + + errorHandler(err, mockReq(), resWithLocals, next); + + expect(releaseQueue).toHaveBeenCalled(); + }); + + it("calls releaseQueue for generic Error", () => { + const releaseQueue = vi.fn(); + const resWithLocals = { ...res, locals: { releaseQueue } } as any; + const err = new Error("something broke"); + + errorHandler(err, mockReq(), resWithLocals, next); + + expect(releaseQueue).toHaveBeenCalled(); + }); + + it("does not throw when releaseQueue is not set", () => { + const resWithNoLocals = { ...res, locals: {} } as any; + const err = new ProcessingError(400, { type: "validation", code: "test" }); + + expect(() => errorHandler(err, mockReq(), resWithNoLocals, next)).not.toThrow(); + }); +}); + +describe("sharedKeyAuth", () => { + let res: ReturnType<typeof mockRes>; + let next: NextFunction; + + beforeEach(() => { + res = mockRes(); + next = vi.fn(); + }); + + it("returns 403 when expectedKey is null", () => { + const middleware = sharedKeyAuth(null); + const req = { headers: {} } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + type: "authorization", + code: "forbidden", + hint: "Shared key not configured", + }); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 403 when expectedKey is null regardless of NODE_ENV", () => { + const originalEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + try { + const middleware = sharedKeyAuth(null); + const req = { headers: {} } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + } finally { + process.env.NODE_ENV = originalEnv; + } + }); + + it("passes through with correct key", () => { + const middleware = sharedKeyAuth("test-key"); + const req = { headers: { "x-shared-key": "test-key" } } as unknown as Request; + middleware(req, res, next); + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("returns 403 with wrong key", () => { + const middleware = sharedKeyAuth("test-key"); + const req = { headers: { "x-shared-key": "wrong-key" } } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ type: "authorization", code: "forbidden" }); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 403 with missing header", () => { + const middleware = sharedKeyAuth("test-key"); + const req = { headers: {} } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ type: "authorization", code: "forbidden" }); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 403 with undefined header value", () => { + const middleware = sharedKeyAuth("test-key"); + const req = { headers: { "x-shared-key": undefined } } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 403 when key is null and NODE_ENV is production", () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; + try { + const middleware = sharedKeyAuth(null); + const req = { headers: {} } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + type: "authorization", + code: "forbidden", + hint: "Shared key not configured", + }); + expect(next).not.toHaveBeenCalled(); + } finally { + process.env.NODE_ENV = originalEnv; + } + }); + + it("returns 403 for multibyte Unicode with same string length but different byte length", () => { + const middleware = sharedKeyAuth("test-key"); + const req = { headers: { "x-shared-key": "test-ké" } } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 403 for emoji input (multibyte)", () => { + const middleware = sharedKeyAuth("test-key"); + const req = { headers: { "x-shared-key": "test-k🔑" } } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 403 for accented characters with same string length", () => { + const middleware = sharedKeyAuth("abcdefgh"); + const req = { headers: { "x-shared-key": "ábcdefgh" } } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); +}); + +describe("timeoutMiddleware", () => { + it("calls next() immediately", () => { + const middleware = timeoutMiddleware(1000); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it("clears timer when response finishes", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(1000); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + middleware(req, res, next); + res.emit("finish"); + + // Advance past timeout - should not throw + vi.advanceTimersByTime(2000); + vi.useRealTimers(); + }); + + it("clears timer when response closes", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(1000); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + middleware(req, res, next); + res.emit("close"); + + // Advance past timeout - should not throw + vi.advanceTimersByTime(2000); + vi.useRealTimers(); + }); + + it("sends 504 response when timeout expires before response", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(100); + const req = new EventEmitter() as unknown as Request; + (req as any).destroy = vi.fn(); + const res = new EventEmitter() as unknown as Response; + (res as any).headersSent = false; + (res as any).status = vi.fn().mockReturnThis(); + (res as any).json = vi.fn().mockReturnThis(); + const next = vi.fn(); + + middleware(req, res, next); + vi.advanceTimersByTime(150); + + expect(res.status).toHaveBeenCalledWith(504); + expect(res.json).toHaveBeenCalledWith({ + type: "internal", + code: "processing-timeout", + hint: "Request timed out", + }); + vi.useRealTimers(); + }); + + it("does not send response if headers already sent", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(100); + const req = new EventEmitter() as unknown as Request; + (req as any).destroy = vi.fn(); + const res = new EventEmitter() as unknown as Response; + (res as any).headersSent = true; + (res as any).status = vi.fn().mockReturnThis(); + (res as any).json = vi.fn().mockReturnThis(); + const next = vi.fn(); + + middleware(req, res, next); + vi.advanceTimersByTime(150); + + expect(res.status).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("destroys request AFTER response finishes (not before)", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(100); + const req = new EventEmitter() as unknown as Request; + (req as any).destroy = vi.fn(); + const res = new EventEmitter() as unknown as Response; + (res as any).headersSent = false; + (res as any).status = vi.fn().mockReturnThis(); + (res as any).json = vi.fn().mockReturnThis(); + const next = vi.fn(); + + middleware(req, res, next); + + // Advance to timeout - this triggers the 504 response + vi.advanceTimersByTime(100); + + // req.destroy should NOT be called yet (response not finished) + expect(req.destroy).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(504); + + // Now simulate response finishing + res.emit("finish"); + + // Now req.destroy should be called + expect(req.destroy).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("creates AbortController and attaches to request", () => { + const middleware = timeoutMiddleware(1000); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + middleware(req, res, next); + + expect((req as any).abortController).toBeDefined(); + expect((req as any).abortController.signal).toBeDefined(); + expect((req as any).abortController.signal.aborted).toBe(false); + }); + + it("aborts signal when timeout fires", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(100); + const req = new EventEmitter() as unknown as Request; + (req as any).destroy = vi.fn(); + const res = new EventEmitter() as unknown as Response; + (res as any).headersSent = false; + (res as any).status = vi.fn().mockReturnThis(); + (res as any).json = vi.fn().mockReturnThis(); + const next = vi.fn(); + + middleware(req, res, next); + + // Signal should not be aborted yet + expect((req as any).abortController.signal.aborted).toBe(false); + + // Advance to timeout + vi.advanceTimersByTime(150); + + // Signal should now be aborted + expect((req as any).abortController.signal.aborted).toBe(true); + vi.useRealTimers(); + }); + + it("does not abort signal when response finishes before timeout", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(1000); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + middleware(req, res, next); + + // Response finishes before timeout + res.emit("finish"); + + // Advance past timeout + vi.advanceTimersByTime(2000); + + // Signal should NOT be aborted (timer was cleared) + expect((req as any).abortController.signal.aborted).toBe(false); + vi.useRealTimers(); + }); + + it("aborts signal when response closes (client disconnect)", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(1000); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + middleware(req, res, next); + + // Signal should not be aborted initially + expect((req as any).abortController.signal.aborted).toBe(false); + + // Simulate client disconnect (response closes) + res.emit("close"); + + // Signal should now be aborted + expect((req as any).abortController.signal.aborted).toBe(true); + vi.useRealTimers(); + }); + + it("does not abort signal again if already aborted when response closes", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(100); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + (res as any).headersSent = false; + (res as any).status = vi.fn().mockReturnValue({ json: vi.fn() }); + const next = vi.fn(); + + middleware(req, res, next); + + // Advance past timeout to trigger abort + vi.advanceTimersByTime(200); + + // Signal should be aborted from timeout + expect((req as any).abortController.signal.aborted).toBe(true); + + // Simulate client disconnect (response closes) + res.emit("close"); + + // Signal should still be aborted (no error thrown) + expect((req as any).abortController.signal.aborted).toBe(true); + vi.useRealTimers(); + }); +}); + +describe("cleanupMiddleware", () => { + it("calls next() immediately", () => { + const req = {} as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + cleanupMiddleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it("removes file on response finish", async () => { + const req = { + file: { + path: "/tmp/test-file.jpg", + }, + } as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + // Mock the rm function by spying on the cleanup behavior + // We'll verify the middleware registers the finish handler + cleanupMiddleware(req, res, next); + + // Emit finish event - this should trigger cleanup + // The actual rm is mocked internally, so we just verify no errors + res.emit("finish"); + + // Wait for async cleanup + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + it("removes file on response close", async () => { + const req = { + file: { + path: "/tmp/test-file.jpg", + }, + } as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + cleanupMiddleware(req, res, next); + + // Emit close event - this should trigger cleanup + res.emit("close"); + + // Wait for async cleanup + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + it("does nothing when req.file is undefined", async () => { + const req = {} as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + cleanupMiddleware(req, res, next); + + // Emit finish event - should not throw + res.emit("finish"); + + // Wait for async cleanup + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + it("does nothing when req.file.path is undefined", async () => { + const req = { + file: { + buffer: Buffer.from("test"), + }, + } as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + cleanupMiddleware(req, res, next); + + // Emit finish event - should not throw + res.emit("finish"); + + // Wait for async cleanup + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + it("doesn't throw when file doesn't exist", async () => { + const req = { + file: { + path: "/tmp/nonexistent-file.jpg", + }, + } as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + cleanupMiddleware(req, res, next); + + // Emit finish event - should not throw even if file doesn't exist + res.emit("finish"); + + // Wait for async cleanup + await new Promise((resolve) => setTimeout(resolve, 10)); + }); +}); diff --git a/media-processor/test/queue.test.ts b/media-processor/test/queue.test.ts new file mode 100644 index 0000000000..cd6d26cc94 --- /dev/null +++ b/media-processor/test/queue.test.ts @@ -0,0 +1,377 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createQueueMiddleware } from "../src/middleware/queue.js"; +import type { Request, Response, NextFunction } from "express"; +import { EventEmitter } from "node:events"; + +function mockRes() { + const res = new EventEmitter() as any; + res.status = vi.fn().mockReturnThis(); + res.json = vi.fn().mockReturnThis(); + res.send = vi.fn().mockReturnThis(); + res.headersSent = false; + res.writableEnded = false; + return res as Response; +} + +function mockReq() { + return {} as Request; +} + +describe("queueMiddleware", () => { + it("calls next() when queue has capacity", async () => { + const middleware = createQueueMiddleware(1); + const req = mockReq(); + const res = mockRes(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it("skips next() when res.writableEnded is true (timeout already sent)", async () => { + const middleware = createQueueMiddleware(1); + const req = mockReq(); + const res = mockRes(); + (res as any).writableEnded = true; + const next = vi.fn(); + + middleware(req, res, next); + + // next() should NOT be called because response already ended + expect(next).not.toHaveBeenCalled(); + }); + + it("queues requests when concurrency limit reached", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + // First request takes the slot + middleware(req1, res1, next1); + expect(next1).toHaveBeenCalled(); + + // Second request should queue + middleware(req2, res2, next2); + expect(next2).not.toHaveBeenCalled(); + + // Release first request's queue slot (simulating processing completion) + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + + // Now second request should proceed + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(next2).toHaveBeenCalled(); + }); + + it("resolves promise when releaseQueue is called", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // Release first request's queue slot + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(next2).toHaveBeenCalled(); + }); + + it("processes requests sequentially with concurrency 1", async () => { + const middleware = createQueueMiddleware(1); + const order: number[] = []; + + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(() => order.push(1)); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(() => order.push(2)); + + const req3 = mockReq(); + const res3 = mockRes(); + const next3 = vi.fn(() => order.push(3)); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + middleware(req3, res3, next3); + + // Only first should be called immediately + expect(next1).toHaveBeenCalled(); + expect(next2).not.toHaveBeenCalled(); + expect(next3).not.toHaveBeenCalled(); + + // Release first request's queue slot + const releaseQueue1 = (res1 as any).locals?.releaseQueue; + expect(releaseQueue1).toBeDefined(); + releaseQueue1(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Now second should be called + expect(next2).toHaveBeenCalled(); + expect(next3).not.toHaveBeenCalled(); + + // Release second request's queue slot + const releaseQueue2 = (res2 as any).locals?.releaseQueue; + expect(releaseQueue2).toBeDefined(); + releaseQueue2(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Now third should be called + expect(next3).toHaveBeenCalled(); + + // Verify sequential order + expect(order).toEqual([1, 2, 3]); + }); + + it("processes requests in parallel with concurrency 10", async () => { + const middleware = createQueueMiddleware(10); + const calls: number[] = []; + + // Create 5 requests (less than concurrency limit) + const requests = Array.from({ length: 5 }, (_, i) => { + const req = mockReq(); + const res = mockRes(); + const next = vi.fn(() => calls.push(i)); + return { req, res, next }; + }); + + // All should be called immediately + requests.forEach(({ req, res, next }) => { + middleware(req, res, next); + }); + + // All 5 should be called immediately since concurrency is 10 + expect(calls.length).toBe(5); + expect(calls).toEqual([0, 1, 2, 3, 4]); + }); + + it("handles request errors gracefully", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // Simulate error by releasing queue slot (as would happen in finally block) + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Second request should still proceed even after first "error" + expect(next2).toHaveBeenCalled(); + }); + + it("doesn't block on slow requests within concurrency limit", async () => { + const middleware = createQueueMiddleware(2); + const calls: number[] = []; + + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(() => calls.push(1)); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(() => calls.push(2)); + + const req3 = mockReq(); + const res3 = mockRes(); + const next3 = vi.fn(() => calls.push(3)); + + // Start first two requests (concurrency is 2) + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // Both should be called immediately + expect(next1).toHaveBeenCalled(); + expect(next2).toHaveBeenCalled(); + expect(next3).not.toHaveBeenCalled(); + + // Third request should wait + middleware(req3, res3, next3); + expect(next3).not.toHaveBeenCalled(); + + // Release first request's queue slot + const releaseQueue1 = (res1 as any).locals?.releaseQueue; + expect(releaseQueue1).toBeDefined(); + releaseQueue1(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Now third should proceed + expect(next3).toHaveBeenCalled(); + }); + + it("releases slot when error handler calls releaseQueue (covers Multer error path)", async () => { + // This test verifies that the error handler releases the queue slot + // by calling releaseQueue from res.locals. This covers the Multer error + // case where the route handler never runs. + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // First request is processing, second is queued + expect(next1).toHaveBeenCalled(); + expect(next2).not.toHaveBeenCalled(); + + // Simulate error handler calling releaseQueue (e.g., Multer error) + const releaseQueue = (res1 as any).locals.releaseQueue; + releaseQueue(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Second request should proceed because slot was released + expect(next2).toHaveBeenCalled(); + }); + + it("releases slot when releaseQueue callback is called", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // First request is processing, second is queued + expect(next1).toHaveBeenCalled(); + expect(next2).not.toHaveBeenCalled(); + + // Simulate processing completing by calling releaseQueue + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Now second request should proceed + expect(next2).toHaveBeenCalled(); + }); + + it("releases slot on processing error (via finally block)", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // First request is processing, second is queued + expect(next1).toHaveBeenCalled(); + expect(next2).not.toHaveBeenCalled(); + + // Simulate processing error and release in finally block + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Second request should proceed even after error + expect(next2).toHaveBeenCalled(); + }); + + it("releaseQueue is idempotent (can be called multiple times)", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // First request is processing, second is queued + expect(next1).toHaveBeenCalled(); + expect(next2).not.toHaveBeenCalled(); + + // Call releaseQueue multiple times + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + releaseQueue(); // Should not throw or cause issues + releaseQueue(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Second request should proceed + expect(next2).toHaveBeenCalled(); + }); + + it("holds queue slot when client disconnects (close event)", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // First request is processing, second is queued + expect(next1).toHaveBeenCalled(); + expect(next2).not.toHaveBeenCalled(); + + // Simulate client disconnect (close event) + res1.emit("close"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Second request should NOT proceed because slot is still held + expect(next2).not.toHaveBeenCalled(); + + // Now release the slot (simulating processing completion) + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Now second request should proceed + expect(next2).toHaveBeenCalled(); + }); +}); diff --git a/media-processor/test/routes-integration.test.ts b/media-processor/test/routes-integration.test.ts new file mode 100644 index 0000000000..26fa0b36de --- /dev/null +++ b/media-processor/test/routes-integration.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import request from "supertest"; +import express from "express"; +import { createImageRoutes } from "../src/routes/image.js"; +import { createFontRoutes } from "../src/routes/font.js"; +import { errorHandler } from "../src/middleware/error-handler.js"; +import { timeoutMiddleware } from "../src/middleware/timeout.js"; +import { sharedKeyAuth } from "../src/middleware/auth.js"; +import { createQueueMiddleware } from "../src/middleware/queue.js"; +import { configureImageLimits } from "../src/services/image.js"; +import { configureFontLimits } from "../src/services/font.js"; +import { configureUploadLimits } from "../src/upload.js"; +import sharp from "sharp"; +import { readdir, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Test app with low memoryThreshold to force disk storage +function createTestApp() { + const app = express(); + + // Configure with low threshold to force disk storage + configureImageLimits({ + maxPixels: 128_000_000, + maxWidth: 16384, + maxHeight: 16384, + }); + + configureFontLimits({ + mem: 1024 * 1024 * 512, + cpuTime: 30, + timeout: 30, + }); + + // Very low threshold to force disk storage for small files + configureUploadLimits({ maxFileSize: 10 * 1024 * 1024, memoryThreshold: 10 }); + + const queueMiddleware = createQueueMiddleware(10); + + app.use(timeoutMiddleware(5000)); + app.use("/api/image", sharedKeyAuth("test-key"), queueMiddleware, createImageRoutes()); + app.use("/api/font", sharedKeyAuth("test-key"), queueMiddleware, createFontRoutes()); + app.use(errorHandler); + + return app; +} + +describe("HTTP upload cleanup", () => { + let app: ReturnType<typeof createTestApp>; + + beforeAll(() => { + app = createTestApp(); + }); + + async function getTempFiles(): Promise<string[]> { + const tmp = tmpdir(); + const files = await readdir(tmp); + const uploadDirs = files.filter((f) => f.startsWith("penpot.upload.")); + + // Get all files inside upload directories + const allFiles: string[] = []; + for (const dir of uploadDirs) { + try { + const dirPath = join(tmp, dir); + const dirFiles = await readdir(dirPath); + allFiles.push(...dirFiles.map((f) => join(dir, f))); + } catch { + // Directory might not exist or be inaccessible + } + } + return allFiles; + } + + it("removes disk-backed file after successful image/info request", async () => { + const beforeFiles = await getTempFiles(); + + // Create a small image + const imageBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const response = await request(app) + .post("/api/image/info") + .set("x-shared-key", "test-key") + .attach("file", imageBuffer, { filename: "test.jpg", contentType: "image/jpeg" }); + + expect(response.status).toBe(200); + expect(response.body.width).toBe(100); + expect(response.body.height).toBe(100); + + // Wait for cleanup + await new Promise((resolve) => setTimeout(resolve, 100)); + + const afterFiles = await getTempFiles(); + + // No new temp files should remain + const newFiles = afterFiles.filter((f) => !beforeFiles.includes(f)); + expect(newFiles.length).toBe(0); + }); + + it("removes disk-backed file after successful image/thumbnail request", async () => { + const beforeFiles = await getTempFiles(); + + const imageBuffer = await sharp({ + create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 255, b: 0 } }, + }) + .png() + .toBuffer(); + + const response = await request(app) + .post("/api/image/thumbnail?width=100&height=100&format=jpeg&mode=fit") + .set("x-shared-key", "test-key") + .attach("file", imageBuffer, { filename: "test.png", contentType: "image/png" }); + + expect(response.status).toBe(200); + expect(response.headers["content-type"]).toMatch(/image\/jpeg/); + + // Wait for cleanup + await new Promise((resolve) => setTimeout(resolve, 100)); + + const afterFiles = await getTempFiles(); + const newFiles = afterFiles.filter((f) => !beforeFiles.includes(f)); + expect(newFiles.length).toBe(0); + }); + + it("removes disk-backed file after successful font/convert request", async () => { + const beforeFiles = await getTempFiles(); + + // Create a minimal TTF font (this is a simplified test - in reality you'd use a real font) + // For this test, we'll just verify the cleanup happens even if the conversion fails + const fontBuffer = Buffer.from("not a real font"); + + const response = await request(app) + .post("/api/font/convert?target-type=font/woff") + .set("x-shared-key", "test-key") + .attach("file", fontBuffer, { filename: "test.ttf", contentType: "font/ttf" }); + + // The conversion will fail, but cleanup should still happen + // We expect either 400 (invalid font) or 500 (processing error) + expect([400, 500]).toContain(response.status); + + // Wait for cleanup + await new Promise((resolve) => setTimeout(resolve, 100)); + + const afterFiles = await getTempFiles(); + const newFiles = afterFiles.filter((f) => !beforeFiles.includes(f)); + expect(newFiles.length).toBe(0); + }); + + it("removes disk-backed file after failed request", async () => { + const beforeFiles = await getTempFiles(); + + // Send invalid image data + const invalidBuffer = Buffer.from("not an image"); + + const response = await request(app) + .post("/api/image/info") + .set("x-shared-key", "test-key") + .attach("file", invalidBuffer, { filename: "invalid.jpg", contentType: "image/jpeg" }); + + expect(response.status).toBe(400); + + // Wait for cleanup + await new Promise((resolve) => setTimeout(resolve, 100)); + + const afterFiles = await getTempFiles(); + const newFiles = afterFiles.filter((f) => !beforeFiles.includes(f)); + expect(newFiles.length).toBe(0); + }); + + it("removes disk-backed file after timeout", async () => { + // Create a test app with very short timeout + const timeoutApp = express(); + configureImageLimits({ maxPixels: 128_000_000, maxWidth: 16384, maxHeight: 16384 }); + configureUploadLimits({ maxFileSize: 10 * 1024 * 1024, memoryThreshold: 10 }); + const queueMiddleware = createQueueMiddleware(10); + timeoutApp.use(timeoutMiddleware(10)); // 10ms timeout - very aggressive + timeoutApp.use("/api/image", sharedKeyAuth("test-key"), queueMiddleware, createImageRoutes()); + timeoutApp.use(errorHandler); + + const beforeFiles = await getTempFiles(); + + // Create a large image that will take time to process + const imageBuffer = await sharp({ + create: { width: 4000, height: 4000, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg({ quality: 100 }) + .toBuffer(); + + const response = await request(timeoutApp) + .post("/api/image/thumbnail?width=2000&height=2000&format=jpeg&mode=fit") + .set("x-shared-key", "test-key") + .attach("file", imageBuffer, { filename: "large.jpg", contentType: "image/jpeg" }); + + // Should timeout + expect(response.status).toBe(504); + expect(response.body.type).toBe("internal"); + expect(response.body.code).toBe("processing-timeout"); + + // Wait for processing to settle (Sharp may still be working in background) + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const afterFiles = await getTempFiles(); + const newFiles = afterFiles.filter((f) => !beforeFiles.includes(f)); + expect(newFiles.length).toBe(0); + }); +}); + +describe("HTTP malformed image handling", () => { + let app: ReturnType<typeof createTestApp>; + + beforeAll(() => { + app = createTestApp(); + }); + + it("returns 400 for corrupted image in /api/image/info", async () => { + // Create a valid image then truncate it + const validBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const corruptedBuffer = validBuffer.subarray(0, Math.floor(validBuffer.length / 2)); + + const response = await request(app) + .post("/api/image/info") + .set("x-shared-key", "test-key") + .attach("file", corruptedBuffer, { filename: "corrupted.jpg", contentType: "image/jpeg" }); + + expect(response.status).toBe(400); + expect(response.body.type).toBe("validation"); + expect(response.body.code).toBe("invalid-image"); + }); + + it("returns 400 for corrupted image in /api/image/thumbnail", async () => { + const validBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const corruptedBuffer = validBuffer.subarray(0, Math.floor(validBuffer.length / 2)); + + const response = await request(app) + .post("/api/image/thumbnail?width=50&height=50&format=jpeg&mode=fit") + .set("x-shared-key", "test-key") + .attach("file", corruptedBuffer, { filename: "corrupted.jpg", contentType: "image/jpeg" }); + + expect(response.status).toBe(400); + expect(response.body.type).toBe("validation"); + expect(response.body.code).toBe("invalid-image"); + }); +}); + +describe("HTTP quality parameter clamping", () => { + let app: ReturnType<typeof createTestApp>; + + beforeAll(() => { + app = createTestApp(); + }); + + it("clamps quality=0 to 1 at route level", async () => { + const imageBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const response = await request(app) + .post("/api/image/thumbnail?width=50&height=50&quality=0&format=jpeg&mode=fit") + .set("x-shared-key", "test-key") + .attach("file", imageBuffer, { filename: "test.jpg", contentType: "image/jpeg" }); + + expect(response.status).toBe(200); + expect(response.headers["content-type"]).toMatch(/image\/jpeg/); + }); + + it("clamps quality=101 to 100 at route level", async () => { + const imageBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const response = await request(app) + .post("/api/image/thumbnail?width=50&height=50&quality=101&format=jpeg&mode=fit") + .set("x-shared-key", "test-key") + .attach("file", imageBuffer, { filename: "test.jpg", contentType: "image/jpeg" }); + + expect(response.status).toBe(200); + expect(response.headers["content-type"]).toMatch(/image\/jpeg/); + }); +}); diff --git a/media-processor/test/setup.ts b/media-processor/test/setup.ts new file mode 100644 index 0000000000..b4c413ce0a --- /dev/null +++ b/media-processor/test/setup.ts @@ -0,0 +1 @@ +process.env.PENPOT_MEDIA_PROCESSOR_LOG_LEVEL = "silent"; diff --git a/media-processor/test/upload-storage.test.ts b/media-processor/test/upload-storage.test.ts new file mode 100644 index 0000000000..b2613e533a --- /dev/null +++ b/media-processor/test/upload-storage.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createHybridStorage } from "../src/upload-storage.js"; +import type { Request } from "express"; +import { Readable } from "node:stream"; +import { rm } from "node:fs/promises"; + +function mockReq(contentLength?: string): Request { + const headers: Record<string, string> = {}; + if (contentLength !== undefined) { + headers["content-length"] = contentLength; + } + return { headers } as Request; +} + +function mockFile(content: string = "test content") { + const stream = Readable.from([content]); + return { + fieldname: "file", + originalname: "test.txt", + encoding: "7bit", + mimetype: "text/plain", + stream, + } as Express.Multer.File; +} + +describe("createHybridStorage", () => { + let storage: ReturnType<typeof createHybridStorage>; + let tempDirs: string[] = []; + + beforeEach(() => { + storage = createHybridStorage({ memoryThreshold: 1024 }); + }); + + afterEach(async () => { + for (const dir of tempDirs) { + await rm(dir, { recursive: true, force: true }); + } + tempDirs = []; + }); + + it("uses memory storage when Content-Length is below threshold", async () => { + const req = mockReq("100"); + const file = mockFile("small content"); + + await new Promise<void>((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeUndefined(); + expect((info as any).buffer).toBeDefined(); + resolve(); + } + }); + }); + }); + + it("uses disk storage when Content-Length is above threshold", async () => { + const req = mockReq("2048"); + const file = mockFile("x".repeat(2048)); + + await new Promise<void>((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeDefined(); + expect((info as any).destination).toBeDefined(); + tempDirs.push((info as any).destination); + resolve(); + } + }); + }); + }); + + it("uses disk storage when Content-Length is absent (chunked transfer)", async () => { + const req = mockReq(); + const file = mockFile("chunked content"); + + await new Promise<void>((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeDefined(); + expect((info as any).destination).toBeDefined(); + tempDirs.push((info as any).destination); + resolve(); + } + }); + }); + }); + + it("uses disk storage when Content-Length is invalid", async () => { + const req = mockReq("not-a-number"); + const file = mockFile("content"); + + await new Promise<void>((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeDefined(); + tempDirs.push((info as any).destination); + resolve(); + } + }); + }); + }); + + it("removes file from disk", async () => { + const req = mockReq("2048"); + const file = mockFile("x".repeat(2048)); + + const info = await new Promise<any>((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else resolve(info); + }); + }); + + tempDirs.push(info.destination); + + await new Promise<void>((resolve, reject) => { + storage._removeFile(req, { ...file, path: info.path } as any, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + }); + + it("uses memory storage when Content-Length is 0", async () => { + const req = mockReq("0"); + const file = mockFile(""); + + await new Promise<void>((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeUndefined(); + expect((info as any).buffer).toBeDefined(); + resolve(); + } + }); + }); + }); + + it("uses disk storage when Content-Length equals threshold", async () => { + const req = mockReq("1024"); + const file = mockFile("x".repeat(1024)); + + await new Promise<void>((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeDefined(); + expect((info as any).destination).toBeDefined(); + tempDirs.push((info as any).destination); + resolve(); + } + }); + }); + }); + + it("uses disk storage when Content-Length is very large", async () => { + const req = mockReq("1073741824"); // 1GB + const file = mockFile("x"); // Small actual content, but large Content-Length + + await new Promise<void>((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeDefined(); + expect((info as any).destination).toBeDefined(); + tempDirs.push((info as any).destination); + resolve(); + } + }); + }); + }); + + it("reuses the same temp directory for concurrent uploads", async () => { + const req1 = mockReq("2048"); + const file1 = mockFile("x".repeat(2048)); + const req2 = mockReq("2048"); + const file2 = mockFile("y".repeat(2048)); + + const [info1, info2] = await Promise.all([ + new Promise<any>((resolve, reject) => { + storage._handleFile(req1, file1, (err, info) => { + if (err) reject(err); + else resolve(info); + }); + }), + new Promise<any>((resolve, reject) => { + storage._handleFile(req2, file2, (err, info) => { + if (err) reject(err); + else resolve(info); + }); + }), + ]); + + tempDirs.push(info1.destination); + tempDirs.push(info2.destination); + + // Both uploads should use the same temp directory + expect(info1.destination).toBe(info2.destination); + }); +}); diff --git a/media-processor/tsconfig.json b/media-processor/tsconfig.json new file mode 100644 index 0000000000..b5edd7fa16 --- /dev/null +++ b/media-processor/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/media-processor/vitest.config.ts b/media-processor/vitest.config.ts new file mode 100644 index 0000000000..ad177edacb --- /dev/null +++ b/media-processor/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + envPrefix: [], + test: { + setupFiles: ["./test/setup.ts"], + }, +}); diff --git a/package.json b/package.json index 610bdf4419..695a8f2e20 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" @@ -19,6 +19,6 @@ "esbuild": "^0.28.1", "mdts": "^0.20.6", "nrepl-client": "^0.3.0", - "playwright": "^1.62.0" + "playwright": "1.62.1" } } diff --git a/plugins/README.md b/plugins/README.md index 9a224135f1..16f8bb92f7 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -92,7 +92,7 @@ This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. -Copyright (c) KALEIDOS INC Sucursal en España SL +Copyright (c) KALEIDOS SUBSIDIARY SL ``` Penpot is a Kaleidos’ [open source project](https://kaleidos.net/) diff --git a/plugins/apps/colors-to-tokens-plugin/package.json b/plugins/apps/colors-to-tokens-plugin/package.json index 17903d2e95..8d6aff2904 100644 --- a/plugins/apps/colors-to-tokens-plugin/package.json +++ b/plugins/apps/colors-to-tokens-plugin/package.json @@ -12,5 +12,6 @@ "init": "concurrently --kill-others --names plugin,serve \"pnpm run watch\" \"pnpm run serve\"", "lint": "eslint .", "test": "vitest" - } + }, + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/composable-test-suite/package.json b/plugins/apps/composable-test-suite/package.json index 36573fd34b..60697f5367 100644 --- a/plugins/apps/composable-test-suite/package.json +++ b/plugins/apps/composable-test-suite/package.json @@ -16,14 +16,15 @@ "clean": "rm -rf dist/" }, "dependencies": { - "@penpot/plugin-styles": "1.4.1", - "@penpot/plugin-types": "1.4.1" + "@penpot/plugin-styles": "1.4.2", + "@penpot/plugin-types": "1.4.2" }, "devDependencies": { - "playwright": "^1.61.1", - "prettier": "^3.6.2", - "typescript": "^5.8.3", - "vite": "^7.0.8", - "vite-live-preview": "^0.3.2" - } + "playwright": "^1.62.1", + "prettier": "^3.9.6", + "typescript": "^5.9.3", + "vite": "^8.2.2", + "vite-live-preview": "^0.4.0" + }, + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpAssert.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpAssert.ts index 3158075b9d..67951c0683 100644 --- a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpAssert.ts +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpAssert.ts @@ -27,13 +27,8 @@ export class OpAssert extends Operation { } async applyTo(situation: Situation): Promise<void> { - try { - this.assertion(situation); - } catch { - // a read may have raced propagation; let it settle and check once more - await PenpotSync.awaitPropagation(); - this.assertion(situation); - } + await PenpotSync.awaitPropagation(); + this.assertion(situation); } toString(): string { diff --git a/plugins/apps/composable-test-suite/src/composable-tests/util/PenpotSync.ts b/plugins/apps/composable-test-suite/src/composable-tests/util/PenpotSync.ts index a2334ce832..ff4db17676 100644 --- a/plugins/apps/composable-test-suite/src/composable-tests/util/PenpotSync.ts +++ b/plugins/apps/composable-test-suite/src/composable-tests/util/PenpotSync.ts @@ -12,6 +12,13 @@ export class PenpotSync { * explicit "wait for propagation" primitive. */ static awaitPropagation(): Promise<void> { - return new Promise((resolve) => setTimeout(resolve, PenpotSync.PROPAGATION_MS)); + // @ts-ignore + if (penpot.waitForLayoutUpdate) { + // @ts-ignore + return penpot.waitForLayoutUpdate(); + } else { + throw new Error("PenpotSync.awaitPropagation: waitForLayoutUpdate is not available"); + return new Promise((resolve) => setTimeout(resolve, PenpotSync.PROPAGATION_MS)); + } } } diff --git a/plugins/apps/contrast-plugin/package.json b/plugins/apps/contrast-plugin/package.json index 01ea179e5d..5e6772cc8e 100644 --- a/plugins/apps/contrast-plugin/package.json +++ b/plugins/apps/contrast-plugin/package.json @@ -12,5 +12,6 @@ "init": "concurrently --kill-others --names plugin,serve \"pnpm run watch\" \"pnpm run serve\"", "lint": "eslint .", "test": "vitest" - } + }, + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/create-palette-plugin/package.json b/plugins/apps/create-palette-plugin/package.json index 919d3035fc..9507e644df 100644 --- a/plugins/apps/create-palette-plugin/package.json +++ b/plugins/apps/create-palette-plugin/package.json @@ -11,5 +11,6 @@ "init": "concurrently --kill-others --names build,serve \"pnpm run watch\" \"pnpm run serve\"", "lint": "eslint .", "test": "vitest" - } + }, + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/e2e/package.json b/plugins/apps/e2e/package.json index d6e4bce356..008fd417ff 100644 --- a/plugins/apps/e2e/package.json +++ b/plugins/apps/e2e/package.json @@ -6,5 +6,6 @@ "scripts": { "test": "vitest", "lint": "eslint ." - } + }, + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/example-styles/package.json b/plugins/apps/example-styles/package.json index 8d88f029cd..59850da654 100644 --- a/plugins/apps/example-styles/package.json +++ b/plugins/apps/example-styles/package.json @@ -10,5 +10,6 @@ "init": "concurrently --kill-others --names build,serve \"pnpm run watch\" \"pnpm run serve\"", "serve": "vite preview", "lint": "eslint ." - } + }, + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/icons-plugin/package.json b/plugins/apps/icons-plugin/package.json index bed848681c..5df19f2d44 100644 --- a/plugins/apps/icons-plugin/package.json +++ b/plugins/apps/icons-plugin/package.json @@ -12,5 +12,6 @@ "init": "concurrently --kill-others --names plugin,serve \"pnpm run build:plugin:watch\" \"pnpm run serve\"", "lint": "eslint .", "test": "vitest" - } + }, + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/lorem-ipsum-plugin/package.json b/plugins/apps/lorem-ipsum-plugin/package.json index f4935c7790..f747085348 100644 --- a/plugins/apps/lorem-ipsum-plugin/package.json +++ b/plugins/apps/lorem-ipsum-plugin/package.json @@ -12,5 +12,6 @@ "init": "concurrently --kill-others --names plugin,serve \"pnpm run watch\" \"pnpm run serve\"", "lint": "eslint .", "test": "vitest" - } + }, + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/plugin-api-test-suite/package.json b/plugins/apps/plugin-api-test-suite/package.json index 305612633c..e88b6354fe 100644 --- a/plugins/apps/plugin-api-test-suite/package.json +++ b/plugins/apps/plugin-api-test-suite/package.json @@ -17,6 +17,7 @@ "test:ci:mocked": "pnpm run build:headless && MOCK_BACKEND=1 tsx ci/run-ci.ts" }, "devDependencies": { - "playwright": "^1.61.1" - } + "playwright": "^1.62.1" + }, + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts index 79c0fb330d..c2b67a4d5b 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts @@ -1,6 +1,13 @@ import { expect, expectReject } from '../framework/expect'; import { describe, test } from '../framework/registry'; -import type { Board, Font, Group, Shape, Text } from '@penpot/plugin-types'; +import type { + Board, + Font, + Group, + Penpot, + Shape, + Text, +} from '@penpot/plugin-types'; import type { TestContext } from '../framework/types'; // waitForLayoutUpdate (context-level and per-shape). @@ -43,8 +50,17 @@ function byX(rects: [Shape, Shape]): { left: Shape; right: Shape } { return a.x <= b.x ? { left: a, right: b } : { left: b, right: a }; } -/** Font ids already handed out by `unloadedFont`. */ -const claimedFonts = new Set<string>(); +/** Tracks fonts used by this test run. */ +const claimedFontsByRun = new WeakMap<Penpot, Set<string>>(); + +function claimedFonts(ctx: TestContext): Set<string> { + let claimed = claimedFontsByRun.get(ctx.penpot); + if (!claimed) { + claimed = new Set<string>(); + claimedFontsByRun.set(ctx.penpot, claimed); + } + return claimed; +} /** * Picks an unclaimed font differing from the text's current one, so assigning @@ -53,11 +69,12 @@ const claimedFonts = new Set<string>(); */ function unloadedFont(ctx: TestContext, t: Text): Font { const all = ctx.penpot.fonts.all; + const claimed = claimedFonts(ctx); for (let i = all.length - 1; i >= 0; i--) { const f = all[i]; if (f.fontId === t.fontId || f.variants.length === 0) continue; - if (claimedFonts.has(f.fontId)) continue; - claimedFonts.add(f.fontId); + if (claimed.has(f.fontId)) continue; + claimed.add(f.fontId); return f; } throw new Error('no alternative font available'); @@ -351,6 +368,89 @@ describe('WaitForLayoutUpdate', () => { }); }); + describe('Components', () => { + test('wait covers propagation from a component main to its copy', async (ctx) => { + const source = ctx.penpot.createRectangle(); + ctx.board.appendChild(source); + const component = ctx.penpot.library.local.createComponent([source]); + const main = component.mainInstance() as Board; + const copy = component.instance() as Board; + ctx.board.appendChild(copy); + await ctx.penpot.waitForLayoutUpdate(); + + const mainChild = main.children[0]; + const copyChild = copy.children[0]; + mainChild.opacity = 0.37; + + await ctx.penpot.waitForLayoutUpdate(); + expect(copyChild.opacity).toBeCloseTo(0.37); + }); + + test('shape wait covers propagation from a component main to its copy', async (ctx) => { + const source = ctx.penpot.createRectangle(); + ctx.board.appendChild(source); + const component = ctx.penpot.library.local.createComponent([source]); + const main = component.mainInstance() as Board; + const copy = component.instance() as Board; + ctx.board.appendChild(copy); + await ctx.penpot.waitForLayoutUpdate(); + + const mainChild = main.children[0]; + const copyChild = copy.children[0]; + mainChild.opacity = 0.63; + + await copyChild.waitForLayoutUpdate(); + expect(copyChild.opacity).toBeCloseTo(0.63); + }); + + test('wait covers layout triggered by component propagation', async (ctx) => { + const host = flexBoard(ctx); + const flex = host.addFlexLayout(); + flex.dir = 'row'; + flex.columnGap = 10; + const first = ctx.penpot.createRectangle(); + first.resize(50, 50); + flex.appendChild(first); + const second = ctx.penpot.createRectangle(); + second.resize(50, 50); + flex.appendChild(second); + + const component = ctx.penpot.library.local.createComponent([host]); + const main = component.mainInstance() as Board; + const copy = component.instance() as Board; + ctx.board.appendChild(copy); + await ctx.penpot.waitForLayoutUpdate(); + + const { left: mainLeft } = byX([main.children[0], main.children[1]]); + mainLeft.resize(120, 50); + + await ctx.penpot.waitForLayoutUpdate(); + const { left: copyLeft, right: copyRight } = byX([ + copy.children[0], + copy.children[1], + ]); + expect(copyLeft.width).toBeCloseTo(120, 0); + expect(copyRight.x - copyLeft.x).toBeCloseTo(130, 0); + }); + }); + + describe('Library assets', () => { + test('wait covers propagation from a local color to a referenced shape', async (ctx) => { + const color = ctx.penpot.library.local.createColor(); + color.color = '#112233'; + color.opacity = 1; + const rect = ctx.penpot.createRectangle(); + rect.fills = [color.asFill()]; + ctx.board.appendChild(rect); + await ctx.penpot.waitForLayoutUpdate(); + + color.color = '#aabbcc'; + + await ctx.penpot.waitForLayoutUpdate(); + expect(rect.fills[0]?.fillColor).toBe('#aabbcc'); + }); + }); + // A font applied to a group reaches its text descendants, so the work is // pending on the children and never on the group itself. Skipped under a // mocked backend for the same reason as the Text group: no fonts are served, diff --git a/plugins/apps/plugin-api-test-suite/src/ui.css b/plugins/apps/plugin-api-test-suite/src/ui.css index 104ac64c90..6e9913f1e4 100644 --- a/plugins/apps/plugin-api-test-suite/src/ui.css +++ b/plugins/apps/plugin-api-test-suite/src/ui.css @@ -60,15 +60,41 @@ body { background-color: var(--background-secondary); } -.group-summary { +.group-header { display: flex; align-items: center; gap: var(--spacing-8, 8px); padding: var(--spacing-8, 8px); +} + +/* Makes the full header row toggle the group. */ +.group-toggle { + display: flex; + flex: 1; + align-items: center; + gap: var(--spacing-8, 8px); + min-width: 0; + margin: 0; + padding: 0; + border: none; + background: none; + color: inherit; + font: inherit; + text-align: start; cursor: pointer; user-select: none; } +.group-chevron { + flex: 0 0 auto; + color: var(--foreground-secondary); + transition: transform 0.15s ease; +} + +.group-toggle[aria-expanded='true'] .group-chevron { + transform: rotate(90deg); +} + .group-name { color: var(--foreground-primary); } @@ -144,6 +170,11 @@ body { padding: 0; } +/* Keeps hidden test lists out of the layout. */ +.test-list[hidden] { + display: none; +} + .test-row { display: grid; grid-template-columns: 1fr auto auto; diff --git a/plugins/apps/plugin-api-test-suite/src/ui.ts b/plugins/apps/plugin-api-test-suite/src/ui.ts index 4aacfd1a8e..fe5fc8a7cf 100644 --- a/plugins/apps/plugin-api-test-suite/src/ui.ts +++ b/plugins/apps/plugin-api-test-suite/src/ui.ts @@ -128,6 +128,13 @@ function reloadIcon(): SVGSVGElement { return svgIcon(['M13 8a5 5 0 1 1-1.46-3.54', 'M13 2.5v3h-3'], false); } +/** Shows whether a group is expanded. */ +function chevronIcon(): SVGSVGElement { + const icon = svgIcon(['M6 3.5 10.5 8 6 12.5'], false); + icon.classList.add('group-chevron'); + return icon; +} + function render() { root.replaceChildren( renderHeader(), @@ -273,9 +280,12 @@ function renderRow(test: TestMeta): HTMLElement { return row; } -function renderGroupSummary( +/** Builds a group header with separate select, toggle, and run controls. */ +function renderGroupHeader( name: string, groupTestList: TestMeta[], + panelId: string, + expanded: boolean, ): HTMLElement { const statuses = groupTestList.map( (t) => results.get(t.id)?.status ?? 'pending', @@ -291,12 +301,12 @@ function renderGroupSummary( const groupCheckbox = el('input', { type: 'checkbox', className: 'checkbox-input', + title: `Select every test in "${name}"`, + ariaLabel: `Select every test in "${name}"`, checked: selectedCount === total && total > 0, disabled: running, }); groupCheckbox.indeterminate = selectedCount > 0 && selectedCount < total; - // Keep the checkbox from toggling the <details> when clicked. - groupCheckbox.addEventListener('click', (e) => e.stopPropagation()); groupCheckbox.addEventListener('change', () => { if (groupCheckbox.checked) ids.forEach((id) => selected.add(id)); else ids.forEach((id) => selected.delete(id)); @@ -305,17 +315,14 @@ function renderGroupSummary( const runButton = el('button', { className: 'icon-button run-group', + type: 'button', title: `Run "${name}"`, ariaLabel: `Run "${name}"`, disabled: running, }); runButton.dataset.appearance = 'secondary'; runButton.append(playIcon()); - runButton.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - run(ids); - }); + runButton.addEventListener('click', () => run(ids)); const counts = el('span', { className: 'group-counts' }, [ el('span', { className: 'count-pass', textContent: `${passed}` }), @@ -327,14 +334,26 @@ function renderGroupSummary( }), ]); - return el('summary', { className: 'group-summary' }, [ - groupCheckbox, + const toggle = el('button', { className: 'group-toggle', type: 'button' }, [ + chevronIcon(), el('span', { className: `status-dot dot-${aggregate}`, title: statusLabel(aggregate), }), el('span', { className: 'group-name', textContent: name }), counts, + ]); + toggle.setAttribute('aria-expanded', String(expanded)); + toggle.setAttribute('aria-controls', panelId); + toggle.addEventListener('click', () => { + if (expanded) expandedGroups.delete(name); + else expandedGroups.add(name); + render(); + }); + + return el('div', { className: 'group-header' }, [ + groupCheckbox, + toggle, runButton, ]); } @@ -342,25 +361,24 @@ function renderGroupSummary( function renderList(): HTMLElement { const container = el('div', { className: 'groups' }); - for (const group of groupTests()) { - const details = el('details', { className: 'group' }); + groupTests().forEach((group, index) => { // Groups are collapsed by default; remember the ones the user expands. - details.open = expandedGroups.has(group.name); - details.addEventListener('toggle', () => { - if (details.open) expandedGroups.add(group.name); - else expandedGroups.delete(group.name); - }); + const expanded = expandedGroups.has(group.name); + const panelId = `group-panel-${index}`; - details.append(renderGroupSummary(group.name, group.tests)); - - const list = el('ul', { className: 'test-list' }); + const list = el('ul', { className: 'test-list', id: panelId }); + list.hidden = !expanded; for (const test of group.tests) { list.append(renderRow(test)); } - details.append(list); - container.append(details); - } + container.append( + el('div', { className: 'group' }, [ + renderGroupHeader(group.name, group.tests, panelId, expanded), + list, + ]), + ); + }); return container; } diff --git a/plugins/apps/poc-state-plugin/package.json b/plugins/apps/poc-state-plugin/package.json index b658551e41..c2db3b3009 100644 --- a/plugins/apps/poc-state-plugin/package.json +++ b/plugins/apps/poc-state-plugin/package.json @@ -12,5 +12,6 @@ "init": "concurrently --kill-others --names plugin,serve \"pnpm run watch\" \"pnpm run serve\"", "lint": "eslint .", "test": "vitest" - } + }, + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/poc-tokens-plugin/package.json b/plugins/apps/poc-tokens-plugin/package.json index 577c40559f..e675d5c538 100644 --- a/plugins/apps/poc-tokens-plugin/package.json +++ b/plugins/apps/poc-tokens-plugin/package.json @@ -12,5 +12,6 @@ "init": "concurrently --kill-others --names plugin,serve \"pnpm run watch\" \"pnpm run serve\"", "lint": "eslint .", "test": "exit 0" - } + }, + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/rename-layers-plugin/package.json b/plugins/apps/rename-layers-plugin/package.json index ecd20a38b6..ca57c4ff7c 100644 --- a/plugins/apps/rename-layers-plugin/package.json +++ b/plugins/apps/rename-layers-plugin/package.json @@ -12,5 +12,6 @@ "init": "concurrently --kill-others --names plugin,serve \"pnpm run watch\" \"pnpm run serve\"", "lint": "eslint .", "test": "vitest" - } + }, + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/table-plugin/package.json b/plugins/apps/table-plugin/package.json index 5626e57444..ccdb6df697 100644 --- a/plugins/apps/table-plugin/package.json +++ b/plugins/apps/table-plugin/package.json @@ -12,5 +12,6 @@ "init": "concurrently --kill-others --names plugin,serve \"pnpm run watch\" \"pnpm run serve\"", "lint": "eslint .", "test": "vitest" - } + }, + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 483cfecca8..83a0c9ec66 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -1353,12 +1353,13 @@ export interface Context { /** * This method returns a promise that will be resolved when all the - * pending layout updates have finished. If no layout work is pending - * the promise resolves immediately. + * pending layout updates have finished and the components have synchronized. + * If no layout work is pending the promise resolves immediately. * @param timeout Maximum time to wait, in milliseconds. If the timeout * elapses before the layout settles, the promise is rejected. Defaults to * 30000; the promise never waits indefinitely. - * @return The promise to be resolved when the layout is updated + * @return The promise to be resolved when the layout is updated. It is + * rejected with an Error, both on timeout and on an invalid timeout value. */ waitForLayoutUpdate(timeout?: number): Promise<void>; } @@ -1645,19 +1646,24 @@ export interface File extends PluginData { * - `'penpot'` will create a *.penpot file with a binary representation of the file * - `'zip'` will create a *.zip with the file exported in several SVG files with some JSON metadata * @param `libraryExportType` indicates what to do with the linked libraries of the file when - * exporting it. Defaults to `all` if not sent. - * - `'all'` will include the libraries as external files that will be exported in a single bundle - * - `'merge'` will add all the assets into the main file and only one file will be imported - * - `'detach'` will unlink all the external assets and no libraries will be imported + * exporting it. Defaults to `'include-libraries'` if not sent. + * - `'include-libraries'` will include the libraries as external files that will be exported in a single bundle + * - `'merge-libraries'` will add all the assets into the main file and only one file will be imported + * - `'detach-libraries'` will unlink all the external assets and no libraries will be imported + * - `'link-later'` will preserve component metadata so instances can be relinked on import * * @example * ```js - * const exportedData = await file.export('penpot', 'all'); + * const exportedData = await file.export('penpot', 'include-libraries'); * ``` */ export( exportType: 'penpot' | 'zip', - libraryExportType?: 'all' | 'merge' | 'detach', + libraryExportType?: + | 'include-libraries' + | 'merge-libraries' + | 'detach-libraries' + | 'link-later', ): Promise<Uint8Array>; /** @@ -4109,13 +4115,14 @@ export interface ShapeBase extends PluginData { remove(): void; /** - * This method returns a promise that will be resolved when the pending - * layout updates for this shape and its children have finished. If no layout - * work is pending for them the promise resolves immediately. + * This method returns a promise that will be resolved when all the + * pending layout updates have finished and the components have synchronized. + * If no layout work is pending the promise resolves immediately. * @param timeout Maximum time to wait, in milliseconds. If the timeout * elapses before the shape's layout settles, the promise is rejected. * Defaults to 30000; the promise never waits indefinitely. - * @return The promise to be resolved when the shape's layout is updated + * @return The promise to be resolved when the shape's layout is updated. It + * is rejected with an Error, both on timeout and on an invalid timeout value. */ waitForLayoutUpdate(timeout?: number): Promise<void>; } diff --git a/plugins/libs/plugin-types/package.json b/plugins/libs/plugin-types/package.json index f79cbe7d85..15935921fe 100644 --- a/plugins/libs/plugin-types/package.json +++ b/plugins/libs/plugin-types/package.json @@ -6,5 +6,6 @@ "scripts": { "build": "node ../../tools/scripts/build-types.mjs", "lint": "tsc -p . --noEmit" - } + }, + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/libs/plugins-runtime/package.json b/plugins/libs/plugins-runtime/package.json index bd56f9de75..fa31aac91a 100644 --- a/plugins/libs/plugins-runtime/package.json +++ b/plugins/libs/plugins-runtime/package.json @@ -15,5 +15,6 @@ "preview": "vite preview", "lint": "eslint .", "test": "vitest" - } + }, + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/libs/plugins-runtime/src/lib/create-plugin.ts b/plugins/libs/plugins-runtime/src/lib/create-plugin.ts index fd7bc6a0a8..fc4ca233f3 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-plugin.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-plugin.ts @@ -39,5 +39,8 @@ export async function createPlugin( plugin, manifest, compartment: sandbox, + get iframeWindow() { + return plugin.iframeWindow; + }, }; } diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts index cff0f5e57a..810ebd54b3 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts @@ -120,15 +120,86 @@ describe('plugin-loader', () => { }); it('should handle messages sent to plugins', async () => { + const mockIframeWindow = { nodeType: 1 } as unknown as Window; + const mockPluginWithIframe = { + plugin: { + close: mockClose, + sendMessage: vi.fn(), + }, + iframeWindow: mockIframeWindow, + manifest: { ...manifest, host: 'http://localhost:4202' }, + } as unknown as Awaited<ReturnType<typeof createPlugin>>; + + vi.mocked(createPlugin).mockResolvedValue(mockPluginWithIframe); + await loadPlugin(manifest); - window.dispatchEvent(new MessageEvent('message', { data: 'test-message' })); + const event = new MessageEvent('message', { + data: 'test-message', + origin: 'http://localhost:4202', + }); + Object.defineProperty(event, 'source', { value: mockIframeWindow }); + window.dispatchEvent(event); - expect(mockPluginApi.plugin.sendMessage).toHaveBeenCalledWith( + expect(mockPluginWithIframe.plugin.sendMessage).toHaveBeenCalledWith( 'test-message', ); }); + it('should reject messages from unrecognized sources', async () => { + await loadPlugin(manifest); + + const event = new MessageEvent('message', { + data: 'malicious-message', + origin: 'https://evil.com', + }); + Object.defineProperty(event, 'source', { + value: { nodeType: 999 } as unknown as Window, + }); + window.dispatchEvent(event); + + expect(mockPluginApi.plugin.sendMessage).not.toHaveBeenCalled(); + }); + + it('should only route messages to the sender plugin', async () => { + const mockIframeWindow1 = { nodeType: 1 } as unknown as Window; + const mockIframeWindow2 = { nodeType: 2 } as unknown as Window; + + const mockPluginApi1 = { + plugin: { + close: vi.fn(), + sendMessage: vi.fn(), + }, + iframeWindow: mockIframeWindow1, + manifest: { ...manifest, host: 'http://localhost:4202' }, + } as unknown as Awaited<ReturnType<typeof createPlugin>>; + + const mockPluginApi2 = { + plugin: { + close: vi.fn(), + sendMessage: vi.fn(), + }, + iframeWindow: mockIframeWindow2, + manifest: { ...manifest, host: 'http://localhost:4203' }, + } as unknown as Awaited<ReturnType<typeof createPlugin>>; + + vi.mocked(createPlugin).mockResolvedValue(mockPluginApi1); + await loadPlugin(manifest); + + vi.mocked(createPlugin).mockResolvedValue(mockPluginApi2); + await loadPlugin(manifest); + + const event = new MessageEvent('message', { + data: 'test', + origin: 'http://localhost:4203', + }); + Object.defineProperty(event, 'source', { value: mockIframeWindow2 }); + window.dispatchEvent(event); + + expect(mockPluginApi2.plugin.sendMessage).toHaveBeenCalledWith('test'); + expect(mockPluginApi1.plugin.sendMessage).not.toHaveBeenCalled(); + }); + it('should load plugin using ɵloadPlugin', async () => { await ɵloadPlugin(manifest); diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts index 8a88f23050..d05178f1f7 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts @@ -30,8 +30,10 @@ const closeAllPlugins = () => { window.addEventListener('message', (event) => { try { - for (const it of plugins) { - it.plugin.sendMessage(event.data); + const senderPlugin = plugins.find((it) => it.iframeWindow === event.source); + + if (senderPlugin) { + senderPlugin.plugin.sendMessage(event.data); } } catch (err) { console.error(err); diff --git a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts index 53ea472494..f943c510a1 100644 --- a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts +++ b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts @@ -6,7 +6,8 @@ import { dragHandler } from '../drag-handler.js'; import modalCss from './plugin.modal.css?inline'; import { resizeModal } from '../create-modal.js'; -const MIN_Z_INDEX = 3; +const MIN_Z_INDEX = 300; +const Z_INDEX_VAR = '--z-index-set'; export class PluginModalElement extends HTMLElement { constructor() { @@ -17,6 +18,7 @@ export class PluginModalElement extends HTMLElement { wrapper = document.createElement('div'); #inner = document.createElement('div'); #dragEvents: ReturnType<typeof dragHandler> | null = null; + #iframe: HTMLIFrameElement | null = null; setTheme(theme: Theme) { if (this.wrapper) { @@ -43,7 +45,19 @@ export class PluginModalElement extends HTMLElement { return Number(modal.style.zIndex); }); - const maxZIndex = Math.max(...zIndexModals, MIN_Z_INDEX); + // Read the application z-index scale via the inherited CSS custom property + // `--z-index-set` (defined on :root). Custom properties pierce shadow DOM + // boundaries, so the value is available even though the modal uses a Shadow + // root. Falls back to MIN_Z_INDEX when the variable is unset or unparseable + // (e.g. when the runtime is used outside the Penpot app shell). + const declared = getComputedStyle(this) + .getPropertyValue(Z_INDEX_VAR) + .trim(); + const parsed = Number(declared); + const baseZIndex = + Number.isFinite(parsed) && parsed > 0 ? parsed : MIN_Z_INDEX; + + const maxZIndex = Math.max(...zIndexModals, baseZIndex); this.style.zIndex = (maxZIndex + 1).toString(); } @@ -97,15 +111,15 @@ export class PluginModalElement extends HTMLElement { header.appendChild(closeButton); - const iframe = document.createElement('iframe'); - iframe.src = iframeSrc; + this.#iframe = document.createElement('iframe'); + this.#iframe.src = iframeSrc; const allowList: string[] = []; if (allowClipboardRead) allowList.push('clipboard-read'); if (allowClipboardWrite) allowList.push('clipboard-write'); - iframe.allow = allowList.join('; '); + this.#iframe.allow = allowList.join('; '); - iframe.sandbox.add( + this.#iframe.sandbox.add( 'allow-scripts', 'allow-forms', 'allow-modals', @@ -116,10 +130,10 @@ export class PluginModalElement extends HTMLElement { ); if (allowDownloads) { - iframe.sandbox.add('allow-downloads'); + this.#iframe.sandbox.add('allow-downloads'); } - iframe.addEventListener('load', () => { + this.#iframe.addEventListener('load', () => { this.shadowRoot?.dispatchEvent( new CustomEvent('load', { composed: true, @@ -146,12 +160,12 @@ export class PluginModalElement extends HTMLElement { ); this.addEventListener('message', (e: Event) => { - if (!iframe.contentWindow) { + if (!this.#iframe?.contentWindow) { return; } try { - iframe.contentWindow.postMessage((e as CustomEvent).detail, '*'); + this.#iframe.contentWindow.postMessage((e as CustomEvent).detail, '*'); } catch (err) { console.error( 'plugin modal: failed to send message to iframe via postMessage.', @@ -164,7 +178,7 @@ export class PluginModalElement extends HTMLElement { this.wrapper.appendChild(this.#inner); this.#inner.appendChild(header); - this.#inner.appendChild(iframe); + this.#inner.appendChild(this.#iframe); const style = document.createElement('style'); style.textContent = modalCss; @@ -174,6 +188,10 @@ export class PluginModalElement extends HTMLElement { this.calculateZIndex(); } + getIframeContentWindow(): Window | null { + return this.#iframe?.contentWindow ?? null; + } + size() { const width = Number(this.wrapper.style.width.replace('px', '') || '300'); const height = Number(this.wrapper.style.height.replace('px', '') || '400'); diff --git a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts index 8b811f55eb..2a2b43e1c6 100644 --- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts +++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts @@ -157,6 +157,9 @@ export async function createPluginManager( } }, getModal: () => modal, + get iframeWindow(): Window | null { + return modal?.getIframeContentWindow() ?? null; + }, registerListener, registerMessageCallback, sendMessage: (message: unknown) => { diff --git a/plugins/libs/plugins-styles/package.json b/plugins/libs/plugins-styles/package.json index 2ba002b6f5..31e7d851b3 100644 --- a/plugins/libs/plugins-styles/package.json +++ b/plugins/libs/plugins-styles/package.json @@ -5,5 +5,6 @@ "scripts": { "build": "node ../../tools/scripts/build-css.mjs", "lint": "echo 0" - } + }, + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/package.json b/plugins/package.json index 344909ffb8..b7ab60fbd0 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -3,7 +3,7 @@ "version": "0.6.0", "type": "module", "license": "MIT", - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "scripts": { "start": "pnpm run start:app:runtime", "start:app:runtime": "concurrently --kill-others --names build,server \"pnpm --filter @penpot/plugins-runtime run build:watch\" \"pnpm --filter @penpot/plugins-runtime run preview\"", @@ -32,29 +32,29 @@ }, "private": true, "devDependencies": { - "@angular-devkit/core": "22.1.0", - "@angular-devkit/schematics": "22.1.0", + "@angular-devkit/core": "22.1.6", + "@angular-devkit/schematics": "22.1.6", "@angular-eslint/eslint-plugin": "22.1.0", "@angular-eslint/eslint-plugin-template": "22.1.0", "@angular-eslint/template-parser": "22.1.0", - "@angular/build": "22.1.0", - "@angular/cli": "22.1.0", - "@angular/compiler-cli": "22.1.0", - "@angular/language-service": "22.1.0", + "@angular/build": "22.1.6", + "@angular/cli": "22.1.6", + "@angular/compiler-cli": "22.1.3", + "@angular/language-service": "22.1.3", "@eslint/js": "10.0.1", - "@schematics/angular": "22.1.0", + "@schematics/angular": "22.1.6", "@types/feather-icons": "^4.29.4", - "@types/node": "26.0.1", + "@types/node": "26.1.2", "@types/yargs": "^17.0.35", - "@typescript-eslint/eslint-plugin": "8.65.0", - "@typescript-eslint/parser": "8.65.0", - "@typescript-eslint/utils": "^8.65.0", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", - "concurrently": "^10.0.4", + "@typescript-eslint/eslint-plugin": "8.68.0", + "@typescript-eslint/parser": "8.68.0", + "@typescript-eslint/utils": "^8.68.0", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "concurrently": "^10.0.5", "dotenv": "^17.4.2", - "esbuild": "^0.28.1", - "eslint": "10.6.0", + "esbuild": "^0.28.2", + "eslint": "10.9.1", "eslint-config-prettier": "10.1.8", "eslint-plugin-import": "2.32.0", "eslint-plugin-jsx-a11y": "6.10.2", @@ -62,33 +62,33 @@ "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-unused-imports": "^4.4.1", "fs-extra": "^11.4.0", - "globals": "^17.8.0", - "happy-dom": "^20.11.1", + "globals": "^17.11.0", + "happy-dom": "^20.11.6", "jiti": "2.7.0", "jsdom": "~30.0.1", - "jsonc-eslint-parser": "^3.1.0", + "jsonc-eslint-parser": "^3.3.0", "prettier": "^3.9.6", - "tsx": "^4.23.1", + "tsx": "^4.23.12", "typedoc": "^0.28.20", "typescript": "6.0.3", - "typescript-eslint": "^8.65.0", - "vite": "8.1.5", + "typescript-eslint": "^8.68.0", + "vite": "8.2.2", "vite-plugin-checker": "^0.14.5", "vite-plugin-dts": "5.0.3", "vite-plugin-static-copy": "^4.1.1", - "vitest": "4.1.10", + "vitest": "4.1.11", "yargs": "^18.1.0" }, "dependencies": { - "@angular/common": "22.1.0", - "@angular/compiler": "22.1.0", - "@angular/core": "22.1.0", - "@angular/forms": "22.1.0", - "@angular/platform-browser": "22.1.0", - "@angular/router": "22.1.0", - "axios": "^1.19.0", + "@angular/common": "22.1.3", + "@angular/compiler": "22.1.3", + "@angular/core": "22.1.3", + "@angular/forms": "22.1.3", + "@angular/platform-browser": "22.1.3", + "@angular/router": "22.1.3", + "axios": "^1.20.0", "feather-icons": "^4.29.2", - "puppeteer": "^25.4.0", + "puppeteer": "^25.9.0", "rxjs": "~7.8.2", "ses": "^2.2.0", "tslib": "^2.8.1", diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml index ec671d53a0..d1d02b8eeb 100644 --- a/plugins/pnpm-lock.yaml +++ b/plugins/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- lockfileVersion: '9.0' settings: @@ -6,7 +107,9 @@ settings: overrides: '@babel/core@<=7.29.0': ^7.29.1 + '@hono/node-server@<2.0.5': ^2.0.5 ajv@>=7.0.0-alpha.0 <8.18.0: ^8.18.0 + brace-expansion@>=4.0.0 <5.0.9: ^5.0.9 lodash@<=4.17.23: ^4.17.24 lodash@>=4.0.0 <=4.17.23: ^4.17.24 minimatch@>=10.0.0 <10.2.1: ^10.2.1 @@ -17,32 +120,32 @@ importers: .: dependencies: '@angular/common': - specifier: 22.1.0 - version: 22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + specifier: 22.1.3 + version: 22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) '@angular/compiler': - specifier: 22.1.0 - version: 22.1.0 + specifier: 22.1.3 + version: 22.1.3 '@angular/core': - specifier: 22.1.0 - version: 22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2) + specifier: 22.1.3 + version: 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) '@angular/forms': - specifier: 22.1.0 - version: 22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) + specifier: 22.1.3 + version: 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) '@angular/platform-browser': - specifier: 22.1.0 - version: 22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)) + specifier: 22.1.3 + version: 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)) '@angular/router': - specifier: 22.1.0 - version: 22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) + specifier: 22.1.3 + version: 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) axios: - specifier: ^1.19.0 - version: 1.19.0 + specifier: ^1.20.0 + version: 1.20.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) feather-icons: specifier: ^4.29.2 version: 4.29.2 puppeteer: - specifier: ^25.4.0 - version: 25.4.0 + specifier: ^25.9.0 + version: 25.9.0 rxjs: specifier: ~7.8.2 version: 7.8.2 @@ -60,101 +163,101 @@ importers: version: 0.16.2 devDependencies: '@angular-devkit/core': - specifier: 22.1.0 - version: 22.1.0(chokidar@5.0.0) + specifier: 22.1.6 + version: 22.1.6(chokidar@5.0.0) '@angular-devkit/schematics': - specifier: 22.1.0 - version: 22.1.0(chokidar@5.0.0) + specifier: 22.1.6 + version: 22.1.6(chokidar@5.0.0) '@angular-eslint/eslint-plugin': specifier: 22.1.0 - version: 22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + version: 22.1.0(@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@angular-eslint/eslint-plugin-template': specifier: 22.1.0 - version: 22.1.0(@angular-eslint/template-parser@22.1.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/types@8.65.0)(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + version: 22.1.0(@angular-eslint/template-parser@22.1.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(@typescript-eslint/types@8.68.0)(@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@angular-eslint/template-parser': specifier: 22.1.0 - version: 22.1.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + version: 22.1.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@angular/build': - specifier: 22.1.0 - version: 22.1.0(@angular/compiler-cli@22.1.0(@angular/compiler@22.1.0)(typescript@6.0.3))(@angular/compiler@22.1.0)(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)))(@types/node@26.0.1)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3)(jiti@2.7.0)(less@4.6.4)(postcss@8.5.25)(rollup@4.60.4)(sass-embedded@1.97.3)(terser@5.46.2)(tslib@2.8.1)(tsx@4.23.1)(typescript@6.0.3)(vitest@4.1.10)(yaml@2.9.0) + specifier: 22.1.6 + version: 22.1.6(@angular/compiler-cli@22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3))(@angular/compiler@22.1.3)(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(@types/node@26.1.2)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3(supports-color@10.2.2))(jiti@2.7.0)(less@4.6.4)(postcss@8.5.25)(rollup@4.60.4)(sass-embedded@1.97.3)(supports-color@10.2.2)(terser@5.46.2)(tslib@2.8.1)(tsx@4.23.12)(typescript@6.0.3)(vitest@4.1.11)(yaml@2.9.0) '@angular/cli': - specifier: 22.1.0 - version: 22.1.0(@types/node@26.0.1)(chokidar@5.0.0) + specifier: 22.1.6 + version: 22.1.6(@types/node@26.1.2)(chokidar@5.0.0)(supports-color@10.2.2) '@angular/compiler-cli': - specifier: 22.1.0 - version: 22.1.0(@angular/compiler@22.1.0)(typescript@6.0.3) + specifier: 22.1.3 + version: 22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3) '@angular/language-service': - specifier: 22.1.0 - version: 22.1.0 + specifier: 22.1.3 + version: 22.1.3 '@eslint/js': specifier: 10.0.1 - version: 10.0.1(eslint@10.6.0(jiti@2.7.0)) + version: 10.0.1(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)) '@schematics/angular': - specifier: 22.1.0 - version: 22.1.0(chokidar@5.0.0) + specifier: 22.1.6 + version: 22.1.6(chokidar@5.0.0) '@types/feather-icons': specifier: ^4.29.4 version: 4.29.4 '@types/node': - specifier: 26.0.1 - version: 26.0.1 + specifier: 26.1.2 + version: 26.1.2 '@types/yargs': specifier: ^17.0.35 version: 17.0.35 '@typescript-eslint/eslint-plugin': - specifier: 8.65.0 - version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + specifier: 8.68.0 + version: 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/parser': - specifier: 8.65.0 - version: 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + specifier: 8.68.0 + version: 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/utils': - specifier: ^8.65.0 - version: 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + specifier: ^8.68.0 + version: 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) '@vitest/coverage-v8': - specifier: 4.1.10 - version: 4.1.10(vitest@4.1.10) + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) '@vitest/ui': - specifier: 4.1.10 - version: 4.1.10(vitest@4.1.10) + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) concurrently: - specifier: ^10.0.4 - version: 10.0.4 + specifier: ^10.0.5 + version: 10.0.5 dotenv: specifier: ^17.4.2 version: 17.4.2 esbuild: - specifier: ^0.28.1 - version: 0.28.1 + specifier: ^0.28.2 + version: 0.28.2 eslint: - specifier: 10.6.0 - version: 10.6.0(jiti@2.7.0) + specifier: 10.9.1 + version: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) eslint-config-prettier: specifier: 10.1.8 - version: 10.1.8(eslint@10.6.0(jiti@2.7.0)) + version: 10.1.8(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)) eslint-plugin-import: specifier: 2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)) + version: 2.32.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) eslint-plugin-jsx-a11y: specifier: 6.10.2 - version: 6.10.2(eslint@10.6.0(jiti@2.7.0)) + version: 6.10.2(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)) eslint-plugin-react: specifier: 7.37.5 - version: 7.37.5(eslint@10.6.0(jiti@2.7.0)) + version: 7.37.5(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)) eslint-plugin-react-hooks: specifier: 7.1.1 - version: 7.1.1(eslint@10.6.0(jiti@2.7.0)) + version: 7.1.1(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) eslint-plugin-unused-imports: specifier: ^4.4.1 - version: 4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)) + version: 4.4.1(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)) fs-extra: specifier: ^11.4.0 version: 11.4.0 globals: - specifier: ^17.8.0 - version: 17.8.0 + specifier: ^17.11.0 + version: 17.11.0 happy-dom: - specifier: ^20.11.1 - version: 20.11.1 + specifier: ^20.11.6 + version: 20.11.6 jiti: specifier: 2.7.0 version: 2.7.0 @@ -162,14 +265,14 @@ importers: specifier: ~30.0.1 version: 30.0.1 jsonc-eslint-parser: - specifier: ^3.1.0 - version: 3.1.0 + specifier: ^3.3.0 + version: 3.3.0 prettier: specifier: ^3.9.6 version: 3.9.6 tsx: - specifier: ^4.23.1 - version: 4.23.1 + specifier: ^4.23.12 + version: 4.23.12 typedoc: specifier: ^0.28.20 version: 0.28.20(typescript@6.0.3) @@ -177,23 +280,23 @@ importers: specifier: 6.0.3 version: 6.0.3 typescript-eslint: - specifier: ^8.65.0 - version: 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + specifier: ^8.68.0 + version: 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) vite: - specifier: 8.1.5 - version: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) + specifier: 8.2.2 + version: 8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) vite-plugin-checker: specifier: ^0.14.5 - version: 0.14.5(eslint@10.6.0(jiti@2.7.0))(optionator@0.9.4)(typescript@6.0.3)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + version: 0.14.5(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@6.0.3)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) vite-plugin-dts: specifier: 5.0.3 - version: 5.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.0.1))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.60.4)(typescript@6.0.3)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.25)) + version: 5.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.1.2))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.2)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25)) vite-plugin-static-copy: specifier: ^4.1.1 - version: 4.1.1(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.1(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) vitest: - specifier: 4.1.10 - version: 4.1.10(@types/node@26.0.1)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + specifier: 4.1.11 + version: 4.1.11(@types/node@26.1.2)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.11.6)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) yargs: specifier: ^18.1.0 version: 18.1.0 @@ -203,27 +306,27 @@ importers: apps/composable-test-suite: dependencies: '@penpot/plugin-styles': - specifier: 1.4.1 - version: 1.4.1 + specifier: 1.4.2 + version: 1.4.2 '@penpot/plugin-types': - specifier: 1.4.1 - version: 1.4.1 + specifier: 1.4.2 + version: 1.4.2 devDependencies: playwright: - specifier: ^1.61.1 - version: 1.61.1 + specifier: ^1.62.1 + version: 1.62.1 prettier: - specifier: ^3.6.2 - version: 3.9.4 + specifier: ^3.9.6 + version: 3.9.6 typescript: - specifier: ^5.8.3 + specifier: ^5.9.3 version: 5.9.3 vite: - specifier: ^7.0.8 - version: 7.3.5(@types/node@26.0.1)(jiti@2.7.0)(less@4.6.4)(lightningcss@1.33.0)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) + specifier: ^8.2.2 + version: 8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) vite-live-preview: - specifier: ^0.3.2 - version: 0.3.2(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(less@4.6.4)(lightningcss@1.33.0)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + specifier: ^0.4.0 + version: 0.4.0(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) apps/contrast-plugin: {} @@ -240,8 +343,8 @@ importers: apps/plugin-api-test-suite: devDependencies: playwright: - specifier: ^1.61.1 - version: 1.61.1 + specifier: ^1.62.1 + version: 1.62.1 apps/poc-state-plugin: {} @@ -273,13 +376,13 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@angular-devkit/architect@0.2201.0': - resolution: {integrity: sha512-5/AOHK/9K5vNJngDCKuja223fNVxQUx4kneGBHr5vsh78Tj1iueCxMy3BnTawsZ54djxW+Qy74Q9zzvdYqm/YQ==} + '@angular-devkit/architect@0.2201.6': + resolution: {integrity: sha512-oGQEdof2/1bZk58PN9dvpGi8pvtbgE5vkP1xRtCqN8dSVxwre1l0pKynOj/Uge1KxjCvs4c1QrQu0vsAnY0vpg==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true - '@angular-devkit/core@22.1.0': - resolution: {integrity: sha512-FUwmS33Yc97FCOEpFlA2L8KISrxMRggBLUzfFkrXcxMtvPs/zTjbKtf/9ElZQyW0+a6TzAj/tyZUf0/FsM3bZg==} + '@angular-devkit/core@22.1.6': + resolution: {integrity: sha512-KLBsZoc2RhOy0xSdeYcLoSOqV/fBP3feBmhY9o12ry2IuyW/17sCmWr6XbIfTIYQN6M98Y1ewmwVNFsvV5b0gA==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: chokidar: ^5.0.0 @@ -287,8 +390,8 @@ packages: chokidar: optional: true - '@angular-devkit/schematics@22.1.0': - resolution: {integrity: sha512-HDF9DvBR7l5tu5Tzkuo7Z4glldV4VsWP61qlbXYgwPcTlVluew6k9P3hUNHSwNozG6nGxLnAcenI3wDsQu2cMQ==} + '@angular-devkit/schematics@22.1.6': + resolution: {integrity: sha512-IbDO9KbQyQm20uinsfT8d9ZtQw41/WVutI/65mAw39WZfN8Ky+MOgOUJCGLiSccMprvZ9YdodBTnN9bLH149Ag==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} '@angular-eslint/bundled-angular-compiler@22.1.0': @@ -323,8 +426,8 @@ packages: eslint: ^9.0.0 || ^10.0.0 typescript: '*' - '@angular/build@22.1.0': - resolution: {integrity: sha512-1AyhEOV+Yc0tB5a0DA0k16H4eO3FHVi1vrIX83Y/C46u0AIedg82zVwi+8jMwOJR/k2FcRnMhAvhDX5ffCTV1A==} + '@angular/build@22.1.6': + resolution: {integrity: sha512-J7JBd3hDAV4dlGNMavDC0KjtvbDd4KOddaom1inwkq92tYnD1ljmhDDigAsUBnRvQhJ82cvagD8zi0s9Ki4rTQ==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: '@angular/compiler': ^22.0.0 @@ -334,7 +437,7 @@ packages: '@angular/platform-browser': ^22.0.0 '@angular/platform-server': ^22.0.0 '@angular/service-worker': ^22.0.0 - '@angular/ssr': ^22.1.0 + '@angular/ssr': ^22.1.6 istanbul-lib-instrument: ^6.0.0 karma: ^6.4.0 less: ^4.2.0 @@ -375,38 +478,38 @@ packages: vitest: optional: true - '@angular/cli@22.1.0': - resolution: {integrity: sha512-0Nf5UNFzF/Uwdc+nylpLNoe7RrRo7VUCYxzC/gixgd+j3Rr0ybZwKL4M6H+blHilSDF5lM6muw8k6CNshQIDfQ==} + '@angular/cli@22.1.6': + resolution: {integrity: sha512-HT3OzYkSpCyXMTgD5G1tsS7vkrY8cYJ/JgSjRjrpwOAooSMtKF1hv7BgBcn79sKg4eaiPLrDpQLXP93vHxgSFg==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true - '@angular/common@22.1.0': - resolution: {integrity: sha512-67L8AS00egxwEKnoMhNDxy+TY+eKOwvwa+os0Odq8nLm7+Qh7JnMVeub8hfncpenOFqlC/RUjO2W9H7Gd2veNA==} + '@angular/common@22.1.3': + resolution: {integrity: sha512-QtMkjhiRd0EnmKR50bw3WbCWYTi6CmA72nnSz1BLQPpaLSi2goloCrPPniHz8fP+w2ESrmmlOWxs1Da3COgnQg==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/core': 22.1.0 + '@angular/core': 22.1.3 rxjs: ^6.5.3 || ^7.4.0 - '@angular/compiler-cli@22.1.0': - resolution: {integrity: sha512-jL89dbzkrV8AeLaxedBgT7ErMnbfi2dvwDJuCrUgm3eCNfbcOpGbNxzH+wDgvbRg2Lhj11/u3JPbW50xA6rvvg==} + '@angular/compiler-cli@22.1.3': + resolution: {integrity: sha512-37lLaDp0RHWZ/lmJqCmIEr0HOM2D5ulHy61gqTBm7KRj3Y6ZaxR8B/JqZmeIpPzKFILVsga+NQ4A8apBUkmezw==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} hasBin: true peerDependencies: - '@angular/compiler': 22.1.0 + '@angular/compiler': 22.1.3 typescript: '>=6.0 <6.1' peerDependenciesMeta: typescript: optional: true - '@angular/compiler@22.1.0': - resolution: {integrity: sha512-WCmuPnuXgqnqrkbrwqQRyldi1k3rlzNLVDl8ntINF7XWuJh0KfQLEkRK0FCCmBztWJkbGug4RBVnKTWlKhRzCQ==} + '@angular/compiler@22.1.3': + resolution: {integrity: sha512-L8Mw2r7bGG/obqgQC+RU3mdFJ3NtLgO5gWhEC1ylcHpLCMPIAXYsMKJIL8dnS78S1wXo/omXwmJ4FiIlCwWahg==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} - '@angular/core@22.1.0': - resolution: {integrity: sha512-X5UaMuOCI4HAvSQIs3QtM+5e0Cni16DRaHUIL3BIBd4ZQNnSH3pZ25TsKQ8Jlu/3hAQ9rzV278kNQcecooGJ7g==} + '@angular/core@22.1.3': + resolution: {integrity: sha512-313+Xkf970AmStJE0E/zNJW/9xvDExQG+6TNltBBl+KJsW0q5dffK2w2PQfV4mtTquBqYoeHSRsms4WgjBKL8g==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/compiler': 22.1.0 + '@angular/compiler': 22.1.3 rxjs: ^6.5.3 || ^7.4.0 zone.js: ~0.15.0 || ~0.16.0 peerDependenciesMeta: @@ -415,37 +518,37 @@ packages: zone.js: optional: true - '@angular/forms@22.1.0': - resolution: {integrity: sha512-nWlSM/pPp78Sx/fBM/tFEgZxdfZe50LkCE2/hkO22Fi1UM2maGc43LDsu/s6l0q9hFep4Wj+xa30KXDBS7Cn8A==} + '@angular/forms@22.1.3': + resolution: {integrity: sha512-b4ual9pgfNqcnEHord50w960DDFIytG3Qb3bu2aCgzmagvRlg9wrtwQNqY+oqY2FVq7c9McNUN7MZRcWl9HNdQ==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/common': 22.1.0 - '@angular/core': 22.1.0 - '@angular/platform-browser': 22.1.0 + '@angular/common': 22.1.3 + '@angular/core': 22.1.3 + '@angular/platform-browser': 22.1.3 rxjs: ^6.5.3 || ^7.4.0 - '@angular/language-service@22.1.0': - resolution: {integrity: sha512-5J+j17o9rvJEiTVotsQfHprPCgKrHhYxz+SpiV25p5mG2qOJ9vX465o/ODbFpRapK8eyHqd/HkPRaGafu3nZkg==} + '@angular/language-service@22.1.3': + resolution: {integrity: sha512-Nc5cHyuYTTH9uENTSmJFiqR85xPGFkpOwxN6Ms2qlqz767FyOXna0iqw2DprNRB1+xG/WtP4RbCI6ofyXHbM0w==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} - '@angular/platform-browser@22.1.0': - resolution: {integrity: sha512-gqUYDUiPfwbaLYdH8WLnOLl3feo3OcNpnMO08HBHaUdi4TLNkC28xwa9fC6ANyYD22QZ5A3abSg8fmR6upWMwg==} + '@angular/platform-browser@22.1.3': + resolution: {integrity: sha512-A8McE6AclwZa2ese4jMfZZu+qZfBFQ4Hl6CaMpzJ1C6Vv6+sXkLu9pouTosJEsUE+etVdepDsqau90lhzgw3Eg==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/animations': 22.1.0 - '@angular/common': 22.1.0 - '@angular/core': 22.1.0 + '@angular/animations': 22.1.3 + '@angular/common': 22.1.3 + '@angular/core': 22.1.3 peerDependenciesMeta: '@angular/animations': optional: true - '@angular/router@22.1.0': - resolution: {integrity: sha512-42Bs0g+tV2gE70Lqnt+VD/+DWbvWwQcg8QgXkTIu3A504tYknrZG/wmvki2AJGyZhmyQ46B4pfXLG4WDP8MFSA==} + '@angular/router@22.1.3': + resolution: {integrity: sha512-23owvZKCpdL7Yh3EzBj4OLf3x0z+jT9b57Qk96wdwI8Lsyf9L78/RJPYCutJ5r+zq3pFM/BHVKyd+2hkfH7N6Q==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/common': 22.1.0 - '@angular/core': 22.1.0 - '@angular/platform-browser': 22.1.0 + '@angular/common': 22.1.3 + '@angular/core': 22.1.3 + '@angular/platform-browser': 22.1.3 rxjs: ^6.5.3 || ^7.4.0 '@asamuzakjp/css-color@6.0.5': @@ -584,6 +687,10 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@babel/types@8.0.4': resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==} engines: {node: ^22.18.0 || >=24.11.0} @@ -599,11 +706,6 @@ packages: '@bufbuild/protobuf@2.13.0': resolution: {integrity: sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g==} - '@commander-js/extra-typings@12.1.0': - resolution: {integrity: sha512-wf/lwQvWAA0goIghcb91dQYpkLBcyhOhQNqG/VgWhnKzgt+UOMvra7EX/2fv70arm5RW+PUHoQHHDa6/p77Eqg==} - peerDependencies: - commander: ~12.1.0 - '@csstools/color-helpers@6.1.0': resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} engines: {node: '>=20.19.0'} @@ -661,6 +763,9 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@emnapi/wasi-threads@1.2.3': + resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} + '@endo/cache-map@1.1.0': resolution: {integrity: sha512-owFGshs/97PDw9oguZqU/px8Lv1d0KjAUtDUiPwKHNXRVUE/jyettEbRoTbNJR1OaI8biMn6bHr9kVJsOh6dXw==} @@ -670,314 +775,158 @@ packages: '@endo/immutable-arraybuffer@1.1.2': resolution: {integrity: sha512-u+NaYB2aqEugQ3u7w3c5QNkPogf8q/xGgsPaqdY6pUiGWtYiTiFspKFcha6+oeZhWXWQ23rf0KrUq0kfuzqYyQ==} - '@esbuild/aix-ppc64@0.27.2': - resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.2': - resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.2': - resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.2': - resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.2': - resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.2': - resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.2': - resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.2': - resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.2': - resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.2': - resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.2': - resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.2': - resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.2': - resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.2': - resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.2': - resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.2': - resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.2': - resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.2': - resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.2': - resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.2': - resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.2': - resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.2': - resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.2': - resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.2': - resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.2': - resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.2': - resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -996,8 +945,8 @@ packages: resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.6.0': - resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/core@1.2.1': @@ -1036,9 +985,9 @@ packages: '@harperfast/extended-iterable@1.0.3': resolution: {integrity: sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==} - '@hono/node-server@1.19.17': - resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} - engines: {node: '>=18.14.1'} + '@hono/node-server@2.0.12': + resolution: {integrity: sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==} + engines: {node: '>=20'} peerDependencies: hono: ^4 @@ -1219,12 +1168,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@listr2/prompt-adapter-inquirer@4.2.4': - resolution: {integrity: sha512-/KRI2DMD7JGSYaREF0Ygl7AefJ/2ase4Gc5cBiKqT5l4tFjsSJfhFGcc5nSkgl0Sp9LkCQNzl/cqbVJYP2L3dw==} + '@listr2/prompt-adapter-inquirer@4.2.5': + resolution: {integrity: sha512-pYGy9dTdTwXdasPgyohkr0HoQ4FrkAzFnsUZl/gcnadDArbpZ8e+fgr+F9WBdNEl2y00mb9bCM4WgmoBkZJ27A==} engines: {node: '>=22.13.0'} peerDependencies: '@inquirer/prompts': '>= 3 < 9' - listr2: 10.2.1 + listr2: 11.0.0 '@lmdb/lmdb-darwin-arm64@3.5.6': resolution: {integrity: sha512-mY5FG4TjPAkY4P0w+OhHaUka5mDh2TX2WKYIwuKzJ1zeW3VvRgxdam/lGJTquI+bthTx5CSHDW+BAQCnNAzkEA==} @@ -1274,8 +1223,8 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} - '@modelcontextprotocol/sdk@1.29.0': - resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -1448,136 +1397,136 @@ packages: '@napi-rs/wasm-runtime@1.0.7': resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} - '@napi-rs/wasm-runtime@1.2.1': - resolution: {integrity: sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==} + '@napi-rs/wasm-runtime@1.2.2': + resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 - '@oxc-parser/binding-android-arm-eabi@0.140.0': - resolution: {integrity: sha512-ZfjDZ422mo7eo3b3VltqNsV9kmv1qt/sPEAMSl64iOSwhVfd0eIZ9LB79Mbs1xYXJnk7WSROwzBCKDIiVxPTvQ==} + '@oxc-parser/binding-android-arm-eabi@0.142.0': + resolution: {integrity: sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxc-parser/binding-android-arm64@0.140.0': - resolution: {integrity: sha512-Ia8jSvikUX6Sf+Ht+KOCUF/k1HpR0VlmqIYymubmWDebOEGtsyliHDR6JxsZ4IX3/c/GbrB1uh09aVGQv/LQmQ==} + '@oxc-parser/binding-android-arm64@0.142.0': + resolution: {integrity: sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxc-parser/binding-darwin-arm64@0.140.0': - resolution: {integrity: sha512-G6VK0nK61pH0d0mBjUqSZbVxGqqO5uzeginLDQj+gOO6ObfJjXRwgkD/ol0w1INcnFeAb6YGGO7qc3ueGHaycQ==} + '@oxc-parser/binding-darwin-arm64@0.142.0': + resolution: {integrity: sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxc-parser/binding-darwin-x64@0.140.0': - resolution: {integrity: sha512-HazBOuZzd2pO1C2uMmp8Gv7mhzMHqKSKDS1OZfcLEvpIcgA+48J92HEtNanVHDIzRD9PRPCV6aS6fkZIWOVl8Q==} + '@oxc-parser/binding-darwin-x64@0.142.0': + resolution: {integrity: sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxc-parser/binding-freebsd-x64@0.140.0': - resolution: {integrity: sha512-9hSUU+HmTUyOe4JzMHxNGgLWNY7rrO+6ShicZwImNJacEAACDMIkuEQQkvXSL+WJN50jaNtLYJv8s4OcBdpyUQ==} + '@oxc-parser/binding-freebsd-x64@0.142.0': + resolution: {integrity: sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxc-parser/binding-linux-arm-gnueabihf@0.140.0': - resolution: {integrity: sha512-RAEuQsYtS0KcDFqN0ABTjyyNlokS91JeuDuoW9tEbG0JTbRNXnpQUdbYc/16JoA6Z/2ALbNrE3KmxtqDiuIjCQ==} + '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': + resolution: {integrity: sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm-musleabihf@0.140.0': - resolution: {integrity: sha512-c4CkHvPvqfojouredJ0w3e6+jiBq0SbFyhH61kr/zPb/7XsaYTNKQ54vmlSsopfdQbNDX40ZeK9Abs2Qet6wcw==} + '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': + resolution: {integrity: sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm64-gnu@0.140.0': - resolution: {integrity: sha512-yrjmLj8ixPB25yqvPGr28meGjb+keed7m1GqqY/0uqkhZIoT4t9zmfwUgFEtC33C7dtE+UQ7TU0IaVxf97SWJg==} + '@oxc-parser/binding-linux-arm64-gnu@0.142.0': + resolution: {integrity: sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-arm64-musl@0.140.0': - resolution: {integrity: sha512-ggGMQTN8Agwxp2WiLMpdY671dt0qTDJWiWlJeig3HnUwTnerRl0J2JdGVghWBeDcss2D9S2V2Js6dZHEiVabVA==} + '@oxc-parser/binding-linux-arm64-musl@0.142.0': + resolution: {integrity: sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-ppc64-gnu@0.140.0': - resolution: {integrity: sha512-IgTs8xYAFgAUGNmR65tIqjlJ8vKgrfXzC515e9goSdfMyKQV4aJpd2pUUudU4u51G64H0/DSEJEXKOraxm9ZCA==} + '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': + resolution: {integrity: sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-gnu@0.140.0': - resolution: {integrity: sha512-A1x+PMWZmSGaFVOx2YeNTFau8uD+QO14/vLP4GrcuvUPs3+nBkUOjy9Lus86ftHsDojjYMbvBelmKc3F7Rv08g==} + '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': + resolution: {integrity: sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-musl@0.140.0': - resolution: {integrity: sha512-zBqpfRo2myWPrPo5xUjeZqlnPXPXsX8BcWtWff66/eGRQdbPjhzPgXa/F+AtxT2afUViPxbuDlwscMKzQ5tg+g==} + '@oxc-parser/binding-linux-riscv64-musl@0.142.0': + resolution: {integrity: sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-s390x-gnu@0.140.0': - resolution: {integrity: sha512-2M1DPm/8w9I//YzFlFC9qXw+r2tJFh5CYwRlYTq2vUJQS7qoQftEDeCZ8EnN7KHtvSiXvYj8mZI5pR7DpXmcEw==} + '@oxc-parser/binding-linux-s390x-gnu@0.142.0': + resolution: {integrity: sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-gnu@0.140.0': - resolution: {integrity: sha512-8aRDbZ/U/jO8N7go1MO72jtbpb4uswV8d7vOkMvt/BPgZiyEYvl1VIWK4ESxZZhnJ4tqwVldgX7dNiP/eB1Jdg==} + '@oxc-parser/binding-linux-x64-gnu@0.142.0': + resolution: {integrity: sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-musl@0.140.0': - resolution: {integrity: sha512-xRqpeI8U2sQQS1W5BMWRyMTxtagkuLG2dEWruet5lFsWHTvBth11/TpSaJatHdqVVwHN0q3uuoS9zRsGinq8hg==} + '@oxc-parser/binding-linux-x64-musl@0.142.0': + resolution: {integrity: sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxc-parser/binding-openharmony-arm64@0.140.0': - resolution: {integrity: sha512-GbGRe26MqAKciFRvXeHNQJ6VAHYs9R4miP89sEAncysM3n+f4lnyLWgsa9kklJNpfnxdq2yRoNYHFqwBckVimw==} + '@oxc-parser/binding-openharmony-arm64@0.142.0': + resolution: {integrity: sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxc-parser/binding-wasm32-wasi@0.140.0': - resolution: {integrity: sha512-vFiC1hqys+hkX1GnQkIoiTQJNiUm43Z0lO35ETKXTw0YtpW7+cN58YRRXFAQQ+TgpkIi3lrhcxdlnqz+Oi3ptQ==} + '@oxc-parser/binding-wasm32-wasi@0.142.0': + resolution: {integrity: sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@oxc-parser/binding-win32-arm64-msvc@0.140.0': - resolution: {integrity: sha512-fGSQldwEYKhM+H8uLt76Op8hh5+FYaR6lvvQ1Txw3Mhn86DyQXLcI0fi1EkFlTK7F+46OCk/j0AJMzZQm6g5Xg==} + '@oxc-parser/binding-win32-arm64-msvc@0.142.0': + resolution: {integrity: sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxc-parser/binding-win32-ia32-msvc@0.140.0': - resolution: {integrity: sha512-sDS2Bai+g3ZWYwfZqmosiSuFDBcVnZ3Ta6pszzsiJoLMqsJEWKcxXXbGa7b7yXr++W2lQNPb3ZRJ8czseqL7RA==} + '@oxc-parser/binding-win32-ia32-msvc@0.142.0': + resolution: {integrity: sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxc-parser/binding-win32-x64-msvc@0.140.0': - resolution: {integrity: sha512-kHbE1zWyb5OQgJA6/5P4WjiuB01sYdQwtZnSSyE58FQEXDAMnyeeq4vj7KgN75i5SlBzOs8A5MrtlD3gOlDKqQ==} + '@oxc-parser/binding-win32-x64-msvc@0.142.0': + resolution: {integrity: sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1588,6 +1537,12 @@ packages: '@oxc-project/types@0.140.0': resolution: {integrity: sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==} + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + '@parcel/watcher-android-arm64@2.6.0': resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} engines: {node: '>= 10.0.0'} @@ -1670,17 +1625,17 @@ packages: resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} engines: {node: '>= 10.0.0'} - '@penpot/plugin-styles@1.4.1': - resolution: {integrity: sha512-6TuJqKQsq1Xmhn2A02R+kCOzIzIdqgFg5z6ncLH2PlAflKIX6aYsGiOF7yFx4RYgCegRVMFPnVis6/hwO+YGQg==} + '@penpot/plugin-styles@1.4.2': + resolution: {integrity: sha512-/Rn6xy80W+mxAi6j5/SOiNY8P0qHMB1WW+j+nyZajecFSnVGPzSWOiMcIDH+Jtsz7Xpmd5AICpgVM0xjechQig==} - '@penpot/plugin-types@1.4.1': - resolution: {integrity: sha512-pHE2B3GI8M5JR03S/NdBoN+z6e1R1IEh3vpFbLG9LN0EZpQE6nEbmCo5jWAWI73Jqlg6CHG/RWVJNmWECnkDTA==} + '@penpot/plugin-types@1.4.2': + resolution: {integrity: sha512-O8wU6RSYE8bIVU7g8cSTYi32ppxs3R13dq7X3Nn9tmDaJjBOKOBpVLuoRPIp3fJC65fv8/7om0sdrtFoL5v19g==} '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@puppeteer/browsers@3.0.6': - resolution: {integrity: sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA==} + '@puppeteer/browsers@3.2.1': + resolution: {integrity: sha512-KDz+3qDRdBAlRlMjmKyj6dEs33YHTk/xRHEENSXq6TNnhgoU15ruSHtEBeVF6OZ9tBDY55Se4P0nFMNsipzU9A==} engines: {node: '>=22.12.0'} hasBin: true peerDependencies: @@ -1692,6 +1647,12 @@ packages: yauzl: optional: true + '@rolldown/binding-android-arm-eabi@1.2.6': + resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@rolldown/binding-android-arm64@1.1.5': resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1704,6 +1665,18 @@ packages: cpu: [arm64] os: [android] + '@rolldown/binding-android-arm64@1.2.2': + resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-android-arm64@1.2.6': + resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-darwin-arm64@1.1.5': resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1716,6 +1689,18 @@ packages: cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.2.2': + resolution: {integrity: sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-arm64@1.2.6': + resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.1.5': resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1728,6 +1713,18 @@ packages: cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.2.2': + resolution: {integrity: sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.6': + resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.1.5': resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1740,6 +1737,18 @@ packages: cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.2.2': + resolution: {integrity: sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-freebsd-x64@1.2.6': + resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1752,6 +1761,18 @@ packages: cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + resolution: {integrity: sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.1.5': resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1766,6 +1787,20 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.2.2': + resolution: {integrity: sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-gnu@1.2.6': + resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.1.5': resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1780,6 +1815,20 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.2.2': + resolution: {integrity: sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-arm64-musl@1.2.6': + resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-ppc64-gnu@1.1.5': resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1794,6 +1843,20 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + resolution: {integrity: sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.1.5': resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1808,6 +1871,20 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.2.2': + resolution: {integrity: sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.6': + resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.1.5': resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1822,6 +1899,20 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.2.2': + resolution: {integrity: sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.6': + resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.1.5': resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1836,6 +1927,20 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-x64-musl@1.2.2': + resolution: {integrity: sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-x64-musl@1.2.6': + resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-openharmony-arm64@1.1.5': resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1848,6 +1953,18 @@ packages: cpu: [arm64] os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.2.2': + resolution: {integrity: sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-openharmony-arm64@1.2.6': + resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.1.5': resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1870,6 +1987,18 @@ packages: cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.2.2': + resolution: {integrity: sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-arm64-msvc@1.2.6': + resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.1.5': resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1882,6 +2011,18 @@ packages: cpu: [x64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.2.2': + resolution: {integrity: sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.6': + resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} @@ -2133,10 +2274,16 @@ packages: '@rushstack/ts-command-line@5.1.7': resolution: {integrity: sha512-Ugwl6flarZcL2nqH5IXFYk3UR3mBVDsVFlCQW/Oaqidvdb/5Ota6b/Z3JXWIdqV3rOR2/JrYoAHanWF5rgenXA==} - '@schematics/angular@22.1.0': - resolution: {integrity: sha512-3nI/qZ75RPadJ0R0YMe6zKZPHYYJXw4U6Ha+HToostCFlIRh52qC+zH0j/pghgyggwMkvRsE9qekKkRUHqPIbw==} + '@schematics/angular@22.1.6': + resolution: {integrity: sha512-RiD4OZJ4yuaM1aXb3pt1gjDSWwkP0jrgiCuoaBls1eabcrDmlsNuQv0ekxbcGgCRPdSJvmHRZIHHmFPzei8w3Q==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@seahax/deep-copy@0.1.0': + resolution: {integrity: sha512-Ux88qw9ypPbqszDGwj0JvP8FP017MV3ck7jnkit+0i3mecTkqhYiCUqy3exbxhGFBu07zrUS6yPRUWVct1eSMQ==} + + '@seahax/semaphore@0.5.1': + resolution: {integrity: sha512-q6SXYYbE6X+LDcq2h2yCgE+pCWJumNP3XCZkztdG4S4tiig9akMZGp8TsfU/EIRcHWPdnQ3BA8/NAvdDYdF/NQ==} + '@shikijs/engine-oniguruma@3.23.0': resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} @@ -2161,18 +2308,12 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@types/ansi-html@0.0.0': - resolution: {integrity: sha512-PEBpUlteD0VW02udY7UjjgjxHwVXmkdanhmRIMkzatGmORJGjzqKylrXVxz1G5xRTEECMxIkwTHpPmZ9Jb7ANQ==} - '@types/argparse@1.0.38': resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/debug@4.1.13': - resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} - '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2209,11 +2350,8 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - - '@types/node@26.0.1': - resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -2230,63 +2368,63 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.65.0': - resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} + '@typescript-eslint/eslint-plugin@8.68.0': + resolution: {integrity: sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.65.0 + '@typescript-eslint/parser': ^8.68.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.65.0': - resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} + '@typescript-eslint/parser@8.68.0': + resolution: {integrity: sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.65.0': - resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} + '@typescript-eslint/project-service@8.68.0': + resolution: {integrity: sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.65.0': - resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} + '@typescript-eslint/scope-manager@8.68.0': + resolution: {integrity: sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.65.0': - resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} + '@typescript-eslint/tsconfig-utils@8.68.0': + resolution: {integrity: sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.65.0': - resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} + '@typescript-eslint/type-utils@8.68.0': + resolution: {integrity: sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.65.0': - resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + '@typescript-eslint/types@8.68.0': + resolution: {integrity: sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.65.0': - resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} + '@typescript-eslint/typescript-estree@8.68.0': + resolution: {integrity: sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.65.0': - resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} + '@typescript-eslint/utils@8.68.0': + resolution: {integrity: sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.65.0': - resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} + '@typescript-eslint/visitor-keys@8.68.0': + resolution: {integrity: sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@vitejs/plugin-basic-ssl@2.3.0': @@ -2295,20 +2433,20 @@ packages: peerDependencies: vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - '@vitest/coverage-v8@4.1.10': - resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} peerDependencies: - '@vitest/browser': 4.1.10 - vitest: 4.1.10 + '@vitest/browser': 4.1.11 + vitest: 4.1.11 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@4.1.10': - resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - '@vitest/mocker@4.1.10': - resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2318,25 +2456,25 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.10': - resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - '@vitest/runner@4.1.10': - resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - '@vitest/snapshot@4.1.10': - resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} - '@vitest/spy@4.1.10': - resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@vitest/ui@4.1.10': - resolution: {integrity: sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==} + '@vitest/ui@4.1.11': + resolution: {integrity: sha512-r/rwyKoev21mWdRGSEkZOqkQ2BYy68mwjihg9M90nNRbf4NGrgzZ4cj6JNCEwlOGJkbKeMgsjlykvwKUbRr7gw==} peerDependencies: - vitest: 4.1.10 + vitest: 4.1.11 - '@vitest/utils@4.1.10': - resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -2346,6 +2484,11 @@ packages: '@volar/typescript@2.4.28': resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -2413,11 +2556,6 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - acorn@8.17.0: resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} engines: {node: '>=0.4.0'} @@ -2478,11 +2616,6 @@ packages: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} - ansi-html@0.0.9: - resolution: {integrity: sha512-ozbS3LuenHVxNRh/wdnN16QapUHzauqSomAl1jwwJRRsGwFwtj644lIhxfWu0Fy0acCij2+AEgHvjscq3dlVXg==} - engines: {'0': node >= 0.8.0} - hasBin: true - ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} @@ -2562,8 +2695,8 @@ packages: resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} engines: {node: '>=4'} - axios@1.19.0: - resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -2576,8 +2709,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.7: - resolution: {integrity: sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==} + baseline-browser-mapping@2.11.11: + resolution: {integrity: sha512-/yImnXwyTvgMkhgekLHok/Rx5vO6E0BmStWlSqKWMVm2a2ITuZ1Tn+9bgLS+gZRdZmWtd8nxuhHpdmCUOWsTQQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -2599,15 +2732,11 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@1.1.15: - resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} - - brace-expansion@5.0.8: - resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} braces@3.0.3: @@ -2689,9 +2818,9 @@ packages: resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} engines: {node: '>=18.20'} - cli-truncate@5.2.0: - resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} - engines: {node: '>=20'} + cli-truncate@6.1.1: + resolution: {integrity: sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==} + engines: {node: '>=22'} cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} @@ -2708,10 +2837,6 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} - commander@12.1.0: - resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} - engines: {node: '>=18'} - commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -2721,8 +2846,8 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concurrently@10.0.4: - resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==} + concurrently@10.0.5: + resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==} engines: {node: '>=22'} hasBin: true @@ -2846,8 +2971,8 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - devtools-protocol@0.0.1653615: - resolution: {integrity: sha512-pGVkY3T/qXxAp2nFPodwYqOevk6ncNMSmvL8QfRCx5ZWGd6Vor7AFNmyaA8Zs6uJyP1QAfjuLandCgvSix1BNA==} + devtools-protocol@0.0.1666840: + resolution: {integrity: sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==} diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} @@ -2881,8 +3006,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.398: - resolution: {integrity: sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==} + electron-to-chromium@1.5.399: + resolution: {integrity: sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2961,13 +3086,8 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - esbuild@0.27.2: - resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} - engines: {node: '>=18'} - hasBin: true - - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -3069,8 +3189,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.6.0: - resolution: {integrity: sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==} + eslint@10.9.1: + resolution: {integrity: sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -3113,9 +3233,6 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - eventemitter3@5.0.4: - resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -3160,8 +3277,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.4: - resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -3296,8 +3413,8 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - globals@17.8.0: - resolution: {integrity: sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==} + globals@17.11.0: + resolution: {integrity: sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==} engines: {node: '>=18'} globalthis@1.0.4: @@ -3311,8 +3428,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - happy-dom@20.11.1: - resolution: {integrity: sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==} + happy-dom@20.11.6: + resolution: {integrity: sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==} engines: {node: '>=20.0.0'} has-bigints@1.1.0: @@ -3352,8 +3469,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.12.32: - resolution: {integrity: sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==} + hono@4.12.34: + resolution: {integrity: sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==} engines: {node: '>=16.9.0'} hosted-git-info@10.1.1: @@ -3424,8 +3541,8 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - ip-address@10.3.1: - resolution: {integrity: sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==} + ip-address@10.4.0: + resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -3595,8 +3712,8 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} - jose@6.2.5: - resolution: {integrity: sha512-2E5L2yRp03FnwreJLJX8/r7mHiZICCf8kG7fAsTWkSQTDAcc46NIZoQLKy+EJ8sPoJlxyS4OQR5H70LjIZZlIQ==} + jose@6.2.8: + resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -3642,8 +3759,8 @@ packages: engines: {node: '>=6'} hasBin: true - jsonc-eslint-parser@3.1.0: - resolution: {integrity: sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==} + jsonc-eslint-parser@3.3.0: + resolution: {integrity: sha512-hYTGkHGNRZnXOFZ1urhINADoqDrGfpy53cjw+dxk84QE0pUDujQzeUeamNs6Mz44/TKD49z2x6/GVSu4ZrtA+Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} jsonc-parser@3.3.1: @@ -3759,8 +3876,8 @@ packages: linkify-it@5.0.2: resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} - listr2@10.2.2: - resolution: {integrity: sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==} + listr2@11.0.0: + resolution: {integrity: sha512-8K88S0aSrcSXdJfiZtEy5BQMnR+TyjrCGLcgAvQs6ta0NEnIm0RJ72/Pv67Jvg07cfBhDbuN74V81lSSVYEFEw==} engines: {node: '>=22.13.0'} lmdb@3.5.6: @@ -3786,9 +3903,9 @@ packages: resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} engines: {node: '>=18'} - log-update@6.1.0: - resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} - engines: {node: '>=18'} + log-update@8.0.0: + resolution: {integrity: sha512-lddSgOt3bPASrylL54ZSpy8nBHns+vBVSoILlVOx+dei300pnLRN958rj/EdlVLKuWlSESU3qdnDZdAI7FXYGg==} + engines: {node: '>=22'} loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} @@ -3895,8 +4012,8 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} - modern-tar@0.7.7: - resolution: {integrity: sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==} + modern-tar@0.8.4: + resolution: {integrity: sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g==} engines: {node: '>=18.0.0'} mrmime@2.0.1: @@ -3922,6 +4039,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -4032,14 +4154,10 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} - oxc-parser@0.140.0: - resolution: {integrity: sha512-h6QFWd6lBMfjESqgQ27GjzrSDb0qbznp7VDQqp2zvgsrWut4vcchyMIzOVXvGQ2GMZgKw9RWrFNWv9WqGL0p7Q==} + oxc-parser@0.142.0: + resolution: {integrity: sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==} engines: {node: ^20.19.0 || >=22.12.0} - p-defer@4.0.1: - resolution: {integrity: sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A==} - engines: {node: '>=12'} - p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -4126,14 +4244,14 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - playwright-core@1.61.1: - resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} - engines: {node: '>=18'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} hasBin: true - playwright@1.61.1: - resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} - engines: {node: '>=18'} + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} hasBin: true possible-typed-array-names@1.1.0: @@ -4149,23 +4267,18 @@ packages: peerDependencies: postcss: ^8.4.31 - postcss@8.5.16: - resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.25: resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.9.4: - resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} - engines: {node: '>=14'} - hasBin: true - prettier@3.9.6: resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} @@ -4209,12 +4322,12 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - puppeteer-core@25.4.0: - resolution: {integrity: sha512-K1plkLOdeoUnGeT1OvdqF3qxl33v+Ra/uH5VyPEhXdMcpvGiEskHzxxEU3fgpccJpJLIipB/rPUsvkZRWeKqOA==} + puppeteer-core@25.9.0: + resolution: {integrity: sha512-U61rCwSMha62CA/Opy6tCx2Fx+ck7ouiKnbpEApzSoLYMoEu9F71nuFpHL55vmIt33/GYm6eKZVhH2ev0nAIeg==} engines: {node: '>=22.12.0'} - puppeteer@25.4.0: - resolution: {integrity: sha512-xfQp8dFBcGaLc1hEMaVr7s+oW4ZkAurr8Y9H81ilKhu6QoLfSTkZjU7IavnyJ/VWpB9ni3KNJUQHUatslLWyGw==} + puppeteer@25.9.0: + resolution: {integrity: sha512-2JqQszD2pyDTpIvBH1ZCXdrHgENVNdJIeOM6asbwHRgWknFiaLd1gNB91w/B/0hQHNpafkpxa90lPpBaJM87Hw==} engines: {node: '>=22.12.0'} hasBin: true @@ -4281,9 +4394,6 @@ packages: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rolldown@1.1.5: resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4294,6 +4404,16 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rolldown@1.2.2: + resolution: {integrity: sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rolldown@1.2.6: + resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup@4.60.4: resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4473,11 +4593,6 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.8.1: - resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} - engines: {node: '>=10'} - hasBin: true - semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -4558,13 +4673,9 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} - slice-ansi@7.1.2: - resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} - engines: {node: '>=18'} - - slice-ansi@8.0.0: - resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} - engines: {node: '>=20'} + slice-ansi@9.0.0: + resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} + engines: {node: '>=22'} source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} @@ -4789,8 +4900,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.1: - resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} engines: {node: '>=18.0.0'} hasBin: true @@ -4828,8 +4939,8 @@ packages: peerDependencies: typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x - typescript-eslint@8.65.0: - resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} + typescript-eslint@8.68.0: + resolution: {integrity: sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4933,11 +5044,14 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - vite-live-preview@0.3.2: - resolution: {integrity: sha512-NrmGaAc85qvkx/+6FluiTo9rLnoY+/NOYnuUvcW5Yb5tSJzUxuloXYrCSS1dtxQB9YKUbpQ95JCb0GRuF//JEQ==} - hasBin: true + verkit@0.3.2: + resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} + engines: {node: '>=18.12.0'} + + vite-live-preview@0.4.0: + resolution: {integrity: sha512-Qz8kr0kixXwnQl+zLPZX66OjajN4jnVnDwhNToJsO6TTboUtBo8pEmRuc0iBmkwW9lXR8mOeMu+QtxFkXBcHYg==} peerDependencies: - vite: '>=5.2.13' + vite: '>=5.4.0' vite-plugin-checker@0.14.5: resolution: {integrity: sha512-c9lQ92eisUO+F7Fd93aelojmiOS+NQpPgQ1XR2LTQHox1/laZf4yAoQj+L3RA9Vgh10e2nFd9b8r2LLyYZsbpA==} @@ -4990,46 +5104,6 @@ packages: peerDependencies: vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - vite@7.3.5: - resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vite@8.1.5: resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5073,20 +5147,63 @@ packages: yaml: optional: true - vitest@4.1.10: - resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.10 - '@vitest/browser-preview': 4.1.10 - '@vitest/browser-webdriverio': 4.1.10 - '@vitest/coverage-istanbul': 4.1.10 - '@vitest/coverage-v8': 4.1.10 - '@vitest/ui': 4.1.10 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -5209,8 +5326,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -5221,8 +5338,8 @@ packages: utf-8-validate: optional: true - ws@8.21.1: - resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -5302,14 +5419,14 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@angular-devkit/architect@0.2201.0(chokidar@5.0.0)': + '@angular-devkit/architect@0.2201.6(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 22.1.0(chokidar@5.0.0) + '@angular-devkit/core': 22.1.6(chokidar@5.0.0) rxjs: 7.8.2 transitivePeerDependencies: - chokidar - '@angular-devkit/core@22.1.0(chokidar@5.0.0)': + '@angular-devkit/core@22.1.6(chokidar@5.0.0)': dependencies: ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) @@ -5320,9 +5437,9 @@ snapshots: optionalDependencies: chokidar: 5.0.0 - '@angular-devkit/schematics@22.1.0(chokidar@5.0.0)': + '@angular-devkit/schematics@22.1.6(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 22.1.0(chokidar@5.0.0) + '@angular-devkit/core': 22.1.6(chokidar@5.0.0) jsonc-parser: 3.3.1 magic-string: 1.0.0 ora: 9.4.1 @@ -5332,61 +5449,61 @@ snapshots: '@angular-eslint/bundled-angular-compiler@22.1.0': {} - '@angular-eslint/eslint-plugin-template@22.1.0(@angular-eslint/template-parser@22.1.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/types@8.65.0)(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@angular-eslint/eslint-plugin-template@22.1.0(@angular-eslint/template-parser@22.1.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(@typescript-eslint/types@8.68.0)(@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 22.1.0 - '@angular-eslint/template-parser': 22.1.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@angular-eslint/utils': 22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@angular-eslint/template-parser': 22.1.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@angular-eslint/utils': 22.1.0(@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) aria-query: 5.3.2 axobject-query: 4.1.0 - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 - '@angular-eslint/eslint-plugin@22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@angular-eslint/eslint-plugin@22.1.0(@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 22.1.0 - '@angular-eslint/utils': 22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.6.0(jiti@2.7.0) + '@angular-eslint/utils': 22.1.0(@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 - '@angular-eslint/template-parser@22.1.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@angular-eslint/template-parser@22.1.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 22.1.0 - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) eslint-scope: 9.1.2 typescript: 6.0.3 - '@angular-eslint/utils@22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@angular-eslint/utils@22.1.0(@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 22.1.0 - '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.6.0(jiti@2.7.0) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 - '@angular/build@22.1.0(@angular/compiler-cli@22.1.0(@angular/compiler@22.1.0)(typescript@6.0.3))(@angular/compiler@22.1.0)(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)))(@types/node@26.0.1)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3)(jiti@2.7.0)(less@4.6.4)(postcss@8.5.25)(rollup@4.60.4)(sass-embedded@1.97.3)(terser@5.46.2)(tslib@2.8.1)(tsx@4.23.1)(typescript@6.0.3)(vitest@4.1.10)(yaml@2.9.0)': + '@angular/build@22.1.6(@angular/compiler-cli@22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3))(@angular/compiler@22.1.3)(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(@types/node@26.1.2)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3(supports-color@10.2.2))(jiti@2.7.0)(less@4.6.4)(postcss@8.5.25)(rollup@4.60.4)(sass-embedded@1.97.3)(supports-color@10.2.2)(terser@5.46.2)(tslib@2.8.1)(tsx@4.23.12)(typescript@6.0.3)(vitest@4.1.11)(yaml@2.9.0)': dependencies: '@ampproject/remapping': 2.3.0 - '@angular-devkit/architect': 0.2201.0(chokidar@5.0.0) - '@angular/compiler': 22.1.0 - '@angular/compiler-cli': 22.1.0(@angular/compiler@22.1.0)(typescript@6.0.3) + '@angular-devkit/architect': 0.2201.6(chokidar@5.0.0) + '@angular/compiler': 22.1.3 + '@angular/compiler-cli': 22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3) '@babel/core': 8.0.1 '@babel/helper-annotate-as-pure': 8.0.0 '@babel/helper-split-export-declaration': 7.24.7 - '@inquirer/confirm': 6.1.1(@types/node@26.0.1) - '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + '@inquirer/confirm': 6.1.1(@types/node@26.1.2) + '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) beasties: 0.4.3 browserslist: 4.28.7 - esbuild: 0.28.1 - https-proxy-agent: 9.1.0 + esbuild: 0.28.2 + https-proxy-agent: 9.1.0(supports-color@10.2.2) jsonc-parser: 3.3.1 - listr2: 10.2.2 + listr2: 11.0.0 magic-string: 1.0.0 mrmime: 2.0.1 - oxc-parser: 0.140.0 + oxc-parser: 0.142.0 parse5-html-rewriting-stream: 8.0.1 picomatch: 4.0.5 piscina: 5.2.0 @@ -5397,17 +5514,17 @@ snapshots: tinyglobby: 0.2.17 tslib: 2.8.1 typescript: 6.0.3 - vite: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) watchpack: 2.5.2 optionalDependencies: - '@angular/core': 22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2) - '@angular/platform-browser': 22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)) - istanbul-lib-instrument: 6.0.3 + '@angular/core': 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/platform-browser': 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)) + istanbul-lib-instrument: 6.0.3(supports-color@10.2.2) less: 4.6.4 lmdb: 3.5.6 postcss: 8.5.25 rollup: 4.60.4 - vitest: 4.1.10(@types/node@26.0.1)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.1.2)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.11.6)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -5422,21 +5539,21 @@ snapshots: - tsx - yaml - '@angular/cli@22.1.0(@types/node@26.0.1)(chokidar@5.0.0)': + '@angular/cli@22.1.6(@types/node@26.1.2)(chokidar@5.0.0)(supports-color@10.2.2)': dependencies: - '@angular-devkit/architect': 0.2201.0(chokidar@5.0.0) - '@angular-devkit/core': 22.1.0(chokidar@5.0.0) - '@angular-devkit/schematics': 22.1.0(chokidar@5.0.0) - '@inquirer/prompts': 8.5.2(@types/node@26.0.1) - '@listr2/prompt-adapter-inquirer': 4.2.4(@inquirer/prompts@8.5.2(@types/node@26.0.1))(@types/node@26.0.1)(listr2@10.2.2) - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) - '@schematics/angular': 22.1.0(chokidar@5.0.0) + '@angular-devkit/architect': 0.2201.6(chokidar@5.0.0) + '@angular-devkit/core': 22.1.6(chokidar@5.0.0) + '@angular-devkit/schematics': 22.1.6(chokidar@5.0.0) + '@inquirer/prompts': 8.5.2(@types/node@26.1.2) + '@listr2/prompt-adapter-inquirer': 4.2.5(@inquirer/prompts@8.5.2(@types/node@26.1.2))(@types/node@26.1.2)(listr2@11.0.0) + '@modelcontextprotocol/sdk': 1.30.0(supports-color@10.2.2)(zod@4.4.3) + '@schematics/angular': 22.1.6(chokidar@5.0.0) jsonc-parser: 3.3.1 - listr2: 10.2.2 + listr2: 11.0.0 npm-package-arg: 14.0.0 parse5-html-rewriting-stream: 8.0.1 semver: 7.8.5 - yargs: 18.0.0 + yargs: 18.1.0 zod: 4.4.3 transitivePeerDependencies: - '@cfworker/json-schema' @@ -5444,15 +5561,15 @@ snapshots: - chokidar - supports-color - '@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2)': + '@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2)': dependencies: - '@angular/core': 22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/core': 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) rxjs: 7.8.2 tslib: 2.8.1 - '@angular/compiler-cli@22.1.0(@angular/compiler@22.1.0)(typescript@6.0.3)': + '@angular/compiler-cli@22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3)': dependencies: - '@angular/compiler': 22.1.0 + '@angular/compiler': 22.1.3 '@babel/core': 8.0.1 '@jridgewell/sourcemap-codec': 1.5.5 chokidar: 5.0.0 @@ -5464,41 +5581,41 @@ snapshots: optionalDependencies: typescript: 6.0.3 - '@angular/compiler@22.1.0': + '@angular/compiler@22.1.3': dependencies: tslib: 2.8.1 - '@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)': + '@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)': dependencies: rxjs: 7.8.2 tslib: 2.8.1 optionalDependencies: - '@angular/compiler': 22.1.0 + '@angular/compiler': 22.1.3 zone.js: 0.16.2 - '@angular/forms@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)': + '@angular/forms@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)': dependencies: - '@angular/common': 22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) - '@angular/core': 22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2) - '@angular/platform-browser': 22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)) + '@angular/common': 22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/platform-browser': 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)) '@standard-schema/spec': 1.1.0 rxjs: 7.8.2 tslib: 2.8.1 zod: 4.4.3 - '@angular/language-service@22.1.0': {} + '@angular/language-service@22.1.3': {} - '@angular/platform-browser@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))': + '@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))': dependencies: - '@angular/common': 22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) - '@angular/core': 22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/common': 22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) tslib: 2.8.1 - '@angular/router@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)': + '@angular/router@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)': dependencies: - '@angular/common': 22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) - '@angular/core': 22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2) - '@angular/platform-browser': 22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)) + '@angular/common': 22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/platform-browser': 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)) rxjs: 7.8.2 tslib: 2.8.1 @@ -5532,20 +5649,20 @@ snapshots: '@babel/compat-data@8.0.0': {} - '@babel/core@7.29.7': + '@babel/core@7.29.7(supports-color@10.2.2)': 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/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -5612,25 +5729,25 @@ snapshots: '@babel/helper-globals@8.0.0': {} - '@babel/helper-module-imports@7.29.7': + '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color '@babel/helper-split-export-declaration@7.24.7': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/helper-string-parser@7.29.7': {} @@ -5674,7 +5791,7 @@ snapshots: '@babel/parser': 8.0.4 '@babel/types': 8.0.4 - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.7(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 @@ -5682,7 +5799,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/template': 7.29.7 '@babel/types': 7.29.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -5701,6 +5818,11 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@8.0.4': dependencies: '@babel/helper-string-parser': 8.0.0 @@ -5715,10 +5837,6 @@ snapshots: '@bufbuild/protobuf@2.13.0': optional: true - '@commander-js/extra-typings@12.1.0(commander@12.1.0)': - dependencies: - commander: 12.1.0 - '@csstools/color-helpers@6.1.0': {} '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -5757,6 +5875,7 @@ snapshots: '@emnapi/core@1.11.3': dependencies: + '@emnapi/wasi-threads': 1.2.3 tslib: 2.8.1 optional: true @@ -5780,184 +5899,111 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.3': + dependencies: + tslib: 2.8.1 + optional: true + '@endo/cache-map@1.1.0': {} '@endo/env-options@1.1.11': {} '@endo/immutable-arraybuffer@1.1.2': {} - '@esbuild/aix-ppc64@0.27.2': + '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/aix-ppc64@0.28.1': + '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm64@0.27.2': + '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-arm64@0.28.1': + '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/android-arm@0.27.2': + '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.28.1': + '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/android-x64@0.27.2': + '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.27.2': + '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/darwin-x64@0.27.2': + '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.27.2': + '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.28.1': + '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.27.2': + '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.28.1': + '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-arm64@0.27.2': + '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.28.1': + '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.27.2': + '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/linux-arm@0.28.1': + '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/linux-ia32@0.27.2': + '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/linux-ia32@0.28.1': + '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/linux-loong64@0.27.2': + '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/linux-loong64@0.28.1': + '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.27.2': + '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/linux-mips64el@0.28.1': + '@esbuild/win32-x64@0.28.2': optional: true - '@esbuild/linux-ppc64@0.27.2': - optional: true - - '@esbuild/linux-ppc64@0.28.1': - optional: true - - '@esbuild/linux-riscv64@0.27.2': - optional: true - - '@esbuild/linux-riscv64@0.28.1': - optional: true - - '@esbuild/linux-s390x@0.27.2': - optional: true - - '@esbuild/linux-s390x@0.28.1': - optional: true - - '@esbuild/linux-x64@0.27.2': - optional: true - - '@esbuild/linux-x64@0.28.1': - optional: true - - '@esbuild/netbsd-arm64@0.27.2': - optional: true - - '@esbuild/netbsd-arm64@0.28.1': - optional: true - - '@esbuild/netbsd-x64@0.27.2': - optional: true - - '@esbuild/netbsd-x64@0.28.1': - optional: true - - '@esbuild/openbsd-arm64@0.27.2': - optional: true - - '@esbuild/openbsd-arm64@0.28.1': - optional: true - - '@esbuild/openbsd-x64@0.27.2': - optional: true - - '@esbuild/openbsd-x64@0.28.1': - optional: true - - '@esbuild/openharmony-arm64@0.27.2': - optional: true - - '@esbuild/openharmony-arm64@0.28.1': - optional: true - - '@esbuild/sunos-x64@0.27.2': - optional: true - - '@esbuild/sunos-x64@0.28.1': - optional: true - - '@esbuild/win32-arm64@0.27.2': - optional: true - - '@esbuild/win32-arm64@0.28.1': - optional: true - - '@esbuild/win32-ia32@0.27.2': - optional: true - - '@esbuild/win32-ia32@0.28.1': - optional: true - - '@esbuild/win32-x64@0.27.2': - optional: true - - '@esbuild/win32-x64@0.28.1': - optional: true - - '@eslint-community/eslint-utils@4.9.1(eslint@10.6.0(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))': dependencies: - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.23.5': + '@eslint/config-array@0.23.5(supports-color@10.2.2)': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3 - minimatch: 10.2.5 + debug: 4.4.3(supports-color@10.2.2) + minimatch: 10.2.6 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.6.0': + '@eslint/config-helpers@0.7.0': dependencies: '@eslint/core': 1.2.1 @@ -5965,9 +6011,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.6.0(jiti@2.7.0))': + '@eslint/js@10.0.1(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))': optionalDependencies: - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) '@eslint/object-schema@3.0.5': {} @@ -5989,9 +6035,9 @@ snapshots: '@harperfast/extended-iterable@1.0.3': optional: true - '@hono/node-server@1.19.17(hono@4.12.32)': + '@hono/node-server@2.0.12(hono@4.12.34)': dependencies: - hono: 4.12.32 + hono: 4.12.34 '@humanfs/core@0.19.2': dependencies: @@ -6011,122 +6057,122 @@ snapshots: '@inquirer/ansi@2.0.7': {} - '@inquirer/checkbox@5.2.1(@types/node@26.0.1)': + '@inquirer/checkbox@5.2.1(@types/node@26.1.2)': dependencies: '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/confirm@6.1.1(@types/node@26.0.1)': + '@inquirer/confirm@6.1.1(@types/node@26.1.2)': dependencies: - '@inquirer/core': 11.2.1(@types/node@26.0.1) - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/core@11.2.1(@types/node@26.0.1)': + '@inquirer/core@11.2.1(@types/node@26.1.2)': dependencies: '@inquirer/ansi': 2.0.7 '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/type': 4.0.7(@types/node@26.1.2) cli-width: 4.1.0 fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 signal-exit: 4.1.0 optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/editor@5.2.2(@types/node@26.0.1)': + '@inquirer/editor@5.2.2(@types/node@26.1.2)': dependencies: - '@inquirer/core': 11.2.1(@types/node@26.0.1) - '@inquirer/external-editor': 3.0.3(@types/node@26.0.1) - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) + '@inquirer/external-editor': 3.0.3(@types/node@26.1.2) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/expand@5.1.1(@types/node@26.0.1)': + '@inquirer/expand@5.1.1(@types/node@26.1.2)': dependencies: - '@inquirer/core': 11.2.1(@types/node@26.0.1) - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/external-editor@3.0.3(@types/node@26.0.1)': + '@inquirer/external-editor@3.0.3(@types/node@26.1.2)': dependencies: chardet: 2.2.0 iconv-lite: 0.7.3 optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 '@inquirer/figures@2.0.7': {} - '@inquirer/input@5.1.2(@types/node@26.0.1)': + '@inquirer/input@5.1.2(@types/node@26.1.2)': dependencies: - '@inquirer/core': 11.2.1(@types/node@26.0.1) - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/number@4.1.1(@types/node@26.0.1)': + '@inquirer/number@4.1.1(@types/node@26.1.2)': dependencies: - '@inquirer/core': 11.2.1(@types/node@26.0.1) - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/password@5.1.1(@types/node@26.0.1)': + '@inquirer/password@5.1.1(@types/node@26.1.2)': dependencies: '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@26.0.1) - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/prompts@8.5.2(@types/node@26.0.1)': + '@inquirer/prompts@8.5.2(@types/node@26.1.2)': dependencies: - '@inquirer/checkbox': 5.2.1(@types/node@26.0.1) - '@inquirer/confirm': 6.1.1(@types/node@26.0.1) - '@inquirer/editor': 5.2.2(@types/node@26.0.1) - '@inquirer/expand': 5.1.1(@types/node@26.0.1) - '@inquirer/input': 5.1.2(@types/node@26.0.1) - '@inquirer/number': 4.1.1(@types/node@26.0.1) - '@inquirer/password': 5.1.1(@types/node@26.0.1) - '@inquirer/rawlist': 5.3.1(@types/node@26.0.1) - '@inquirer/search': 4.2.1(@types/node@26.0.1) - '@inquirer/select': 5.2.1(@types/node@26.0.1) + '@inquirer/checkbox': 5.2.1(@types/node@26.1.2) + '@inquirer/confirm': 6.1.1(@types/node@26.1.2) + '@inquirer/editor': 5.2.2(@types/node@26.1.2) + '@inquirer/expand': 5.1.1(@types/node@26.1.2) + '@inquirer/input': 5.1.2(@types/node@26.1.2) + '@inquirer/number': 4.1.1(@types/node@26.1.2) + '@inquirer/password': 5.1.1(@types/node@26.1.2) + '@inquirer/rawlist': 5.3.1(@types/node@26.1.2) + '@inquirer/search': 4.2.1(@types/node@26.1.2) + '@inquirer/select': 5.2.1(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/rawlist@5.3.1(@types/node@26.0.1)': + '@inquirer/rawlist@5.3.1(@types/node@26.1.2)': dependencies: - '@inquirer/core': 11.2.1(@types/node@26.0.1) - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/search@4.2.1(@types/node@26.0.1)': + '@inquirer/search@4.2.1(@types/node@26.1.2)': dependencies: - '@inquirer/core': 11.2.1(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/select@5.2.1(@types/node@26.0.1)': + '@inquirer/select@5.2.1(@types/node@26.1.2)': dependencies: '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/type@4.0.7(@types/node@26.0.1)': + '@inquirer/type@4.0.7(@types/node@26.1.2)': optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 '@istanbuljs/schema@0.1.6': optional: true @@ -6156,11 +6202,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@listr2/prompt-adapter-inquirer@4.2.4(@inquirer/prompts@8.5.2(@types/node@26.0.1))(@types/node@26.0.1)(listr2@10.2.2)': + '@listr2/prompt-adapter-inquirer@4.2.5(@inquirer/prompts@8.5.2(@types/node@26.1.2))(@types/node@26.1.2)(listr2@11.0.0)': dependencies: - '@inquirer/prompts': 8.5.2(@types/node@26.0.1) - '@inquirer/type': 4.0.7(@types/node@26.0.1) - listr2: 10.2.2 + '@inquirer/prompts': 8.5.2(@types/node@26.1.2) + '@inquirer/type': 4.0.7(@types/node@26.1.2) + listr2: 11.0.0 transitivePeerDependencies: - '@types/node' @@ -6185,24 +6231,24 @@ snapshots: '@lmdb/lmdb-win32-x64@3.5.6': optional: true - '@microsoft/api-extractor-model@7.32.2(@types/node@26.0.1)': + '@microsoft/api-extractor-model@7.32.2(@types/node@26.1.2)': dependencies: '@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc-config': 0.18.1 - '@rushstack/node-core-library': 5.19.1(@types/node@26.0.1) + '@rushstack/node-core-library': 5.19.1(@types/node@26.1.2) transitivePeerDependencies: - '@types/node' optional: true - '@microsoft/api-extractor@7.56.0(@types/node@26.0.1)': + '@microsoft/api-extractor@7.56.0(@types/node@26.1.2)': dependencies: - '@microsoft/api-extractor-model': 7.32.2(@types/node@26.0.1) + '@microsoft/api-extractor-model': 7.32.2(@types/node@26.1.2) '@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc-config': 0.18.1 - '@rushstack/node-core-library': 5.19.1(@types/node@26.0.1) + '@rushstack/node-core-library': 5.19.1(@types/node@26.1.2) '@rushstack/rig-package': 0.6.0 - '@rushstack/terminal': 0.21.0(@types/node@26.0.1) - '@rushstack/ts-command-line': 5.1.7(@types/node@26.0.1) + '@rushstack/terminal': 0.21.0(@types/node@26.1.2) + '@rushstack/ts-command-line': 5.1.7(@types/node@26.1.2) diff: 8.0.4 lodash: 4.18.1 minimatch: 10.2.6 @@ -6225,9 +6271,9 @@ snapshots: '@microsoft/tsdoc@0.16.0': optional: true - '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + '@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)': dependencies: - '@hono/node-server': 1.19.17(hono@4.12.32) + '@hono/node-server': 2.0.12(hono@4.12.34) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -6235,10 +6281,10 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.0 - express: 5.2.1 - express-rate-limit: 8.6.1(express@5.2.1) - hono: 4.12.32 - jose: 6.2.5 + express: 5.2.1(supports-color@10.2.2) + express-rate-limit: 8.6.1(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2) + hono: 4.12.34 + jose: 6.2.8 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -6375,88 +6421,92 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.2.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: '@emnapi/core': 1.11.2 '@emnapi/runtime': 1.11.2 '@tybys/wasm-util': 0.10.3 optional: true - '@oxc-parser/binding-android-arm-eabi@0.140.0': + '@oxc-parser/binding-android-arm-eabi@0.142.0': optional: true - '@oxc-parser/binding-android-arm64@0.140.0': + '@oxc-parser/binding-android-arm64@0.142.0': optional: true - '@oxc-parser/binding-darwin-arm64@0.140.0': + '@oxc-parser/binding-darwin-arm64@0.142.0': optional: true - '@oxc-parser/binding-darwin-x64@0.140.0': + '@oxc-parser/binding-darwin-x64@0.142.0': optional: true - '@oxc-parser/binding-freebsd-x64@0.140.0': + '@oxc-parser/binding-freebsd-x64@0.142.0': optional: true - '@oxc-parser/binding-linux-arm-gnueabihf@0.140.0': + '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': optional: true - '@oxc-parser/binding-linux-arm-musleabihf@0.140.0': + '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': optional: true - '@oxc-parser/binding-linux-arm64-gnu@0.140.0': + '@oxc-parser/binding-linux-arm64-gnu@0.142.0': optional: true - '@oxc-parser/binding-linux-arm64-musl@0.140.0': + '@oxc-parser/binding-linux-arm64-musl@0.142.0': optional: true - '@oxc-parser/binding-linux-ppc64-gnu@0.140.0': + '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': optional: true - '@oxc-parser/binding-linux-riscv64-gnu@0.140.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': optional: true - '@oxc-parser/binding-linux-riscv64-musl@0.140.0': + '@oxc-parser/binding-linux-riscv64-musl@0.142.0': optional: true - '@oxc-parser/binding-linux-s390x-gnu@0.140.0': + '@oxc-parser/binding-linux-s390x-gnu@0.142.0': optional: true - '@oxc-parser/binding-linux-x64-gnu@0.140.0': + '@oxc-parser/binding-linux-x64-gnu@0.142.0': optional: true - '@oxc-parser/binding-linux-x64-musl@0.140.0': + '@oxc-parser/binding-linux-x64-musl@0.142.0': optional: true - '@oxc-parser/binding-openharmony-arm64@0.140.0': + '@oxc-parser/binding-openharmony-arm64@0.142.0': optional: true - '@oxc-parser/binding-wasm32-wasi@0.140.0': + '@oxc-parser/binding-wasm32-wasi@0.142.0': dependencies: '@emnapi/core': 1.11.2 '@emnapi/runtime': 1.11.2 - '@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) optional: true - '@oxc-parser/binding-win32-arm64-msvc@0.140.0': + '@oxc-parser/binding-win32-arm64-msvc@0.142.0': optional: true - '@oxc-parser/binding-win32-ia32-msvc@0.140.0': + '@oxc-parser/binding-win32-ia32-msvc@0.142.0': optional: true - '@oxc-parser/binding-win32-x64-msvc@0.140.0': + '@oxc-parser/binding-win32-x64-msvc@0.142.0': optional: true '@oxc-project/types@0.139.0': {} '@oxc-project/types@0.140.0': {} + '@oxc-project/types@0.142.0': {} + + '@oxc-project/types@0.147.0': {} + '@parcel/watcher-android-arm64@2.6.0': optional: true @@ -6514,101 +6564,176 @@ snapshots: '@parcel/watcher-win32-x64': 2.6.0 optional: true - '@penpot/plugin-styles@1.4.1': {} + '@penpot/plugin-styles@1.4.2': {} - '@penpot/plugin-types@1.4.1': {} + '@penpot/plugin-types@1.4.2': {} '@polka/url@1.0.0-next.29': {} - '@puppeteer/browsers@3.0.6': + '@puppeteer/browsers@3.2.1': dependencies: - modern-tar: 0.7.7 + modern-tar: 0.8.4 yargs: 18.1.0 + '@rolldown/binding-android-arm-eabi@1.2.6': + optional: true + '@rolldown/binding-android-arm64@1.1.5': optional: true '@rolldown/binding-android-arm64@1.2.0': optional: true + '@rolldown/binding-android-arm64@1.2.2': + optional: true + + '@rolldown/binding-android-arm64@1.2.6': + optional: true + '@rolldown/binding-darwin-arm64@1.1.5': optional: true '@rolldown/binding-darwin-arm64@1.2.0': optional: true + '@rolldown/binding-darwin-arm64@1.2.2': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.6': + optional: true + '@rolldown/binding-darwin-x64@1.1.5': optional: true '@rolldown/binding-darwin-x64@1.2.0': optional: true + '@rolldown/binding-darwin-x64@1.2.2': + optional: true + + '@rolldown/binding-darwin-x64@1.2.6': + optional: true + '@rolldown/binding-freebsd-x64@1.1.5': optional: true '@rolldown/binding-freebsd-x64@1.2.0': optional: true + '@rolldown/binding-freebsd-x64@1.2.2': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.6': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.2.0': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.1.5': optional: true '@rolldown/binding-linux-arm64-gnu@1.2.0': optional: true + '@rolldown/binding-linux-arm64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.6': + optional: true + '@rolldown/binding-linux-arm64-musl@1.1.5': optional: true '@rolldown/binding-linux-arm64-musl@1.2.0': optional: true + '@rolldown/binding-linux-arm64-musl@1.2.2': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.6': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.1.5': optional: true '@rolldown/binding-linux-ppc64-gnu@1.2.0': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.1.5': optional: true '@rolldown/binding-linux-s390x-gnu@1.2.0': optional: true + '@rolldown/binding-linux-s390x-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.6': + optional: true + '@rolldown/binding-linux-x64-gnu@1.1.5': optional: true '@rolldown/binding-linux-x64-gnu@1.2.0': optional: true + '@rolldown/binding-linux-x64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.6': + optional: true + '@rolldown/binding-linux-x64-musl@1.1.5': optional: true '@rolldown/binding-linux-x64-musl@1.2.0': optional: true + '@rolldown/binding-linux-x64-musl@1.2.2': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.6': + optional: true + '@rolldown/binding-openharmony-arm64@1.1.5': optional: true '@rolldown/binding-openharmony-arm64@1.2.0': optional: true + '@rolldown/binding-openharmony-arm64@1.2.2': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.6': + optional: true + '@rolldown/binding-wasm32-wasi@1.1.5': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true '@rolldown/binding-wasm32-wasi@1.2.0': dependencies: '@emnapi/core': 1.11.2 '@emnapi/runtime': 1.11.2 - '@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) optional: true '@rolldown/binding-win32-arm64-msvc@1.1.5': @@ -6617,12 +6742,24 @@ snapshots: '@rolldown/binding-win32-arm64-msvc@1.2.0': optional: true + '@rolldown/binding-win32-arm64-msvc@1.2.2': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.6': + optional: true + '@rolldown/binding-win32-x64-msvc@1.1.5': optional: true '@rolldown/binding-win32-x64-msvc@1.2.0': optional: true + '@rolldown/binding-win32-x64-msvc@1.2.2': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.6': + optional: true + '@rolldown/pluginutils@1.0.1': {} '@rollup/pluginutils@5.4.0(rollup@4.60.4)': @@ -6768,7 +6905,7 @@ snapshots: '@rtsao/scc@1.1.0': {} - '@rushstack/node-core-library@5.19.1(@types/node@26.0.1)': + '@rushstack/node-core-library@5.19.1(@types/node@26.1.2)': dependencies: ajv: 8.20.0 ajv-draft-04: 1.0.0(ajv@8.20.0) @@ -6779,12 +6916,12 @@ snapshots: resolve: 1.22.12 semver: 7.5.4 optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 optional: true - '@rushstack/problem-matcher@0.1.1(@types/node@26.0.1)': + '@rushstack/problem-matcher@0.1.1(@types/node@26.1.2)': optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 optional: true '@rushstack/rig-package@0.6.0': @@ -6793,18 +6930,18 @@ snapshots: strip-json-comments: 3.1.1 optional: true - '@rushstack/terminal@0.21.0(@types/node@26.0.1)': + '@rushstack/terminal@0.21.0(@types/node@26.1.2)': dependencies: - '@rushstack/node-core-library': 5.19.1(@types/node@26.0.1) - '@rushstack/problem-matcher': 0.1.1(@types/node@26.0.1) + '@rushstack/node-core-library': 5.19.1(@types/node@26.1.2) + '@rushstack/problem-matcher': 0.1.1(@types/node@26.1.2) supports-color: 8.1.1 optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 optional: true - '@rushstack/ts-command-line@5.1.7(@types/node@26.0.1)': + '@rushstack/ts-command-line@5.1.7(@types/node@26.1.2)': dependencies: - '@rushstack/terminal': 0.21.0(@types/node@26.0.1) + '@rushstack/terminal': 0.21.0(@types/node@26.1.2) '@types/argparse': 1.0.38 argparse: 1.0.10 string-argv: 0.3.2 @@ -6812,15 +6949,19 @@ snapshots: - '@types/node' optional: true - '@schematics/angular@22.1.0(chokidar@5.0.0)': + '@schematics/angular@22.1.6(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 22.1.0(chokidar@5.0.0) - '@angular-devkit/schematics': 22.1.0(chokidar@5.0.0) + '@angular-devkit/core': 22.1.6(chokidar@5.0.0) + '@angular-devkit/schematics': 22.1.6(chokidar@5.0.0) jsonc-parser: 3.3.1 typescript: 6.0.3 transitivePeerDependencies: - chokidar + '@seahax/deep-copy@0.1.0': {} + + '@seahax/semaphore@0.5.1': {} + '@shikijs/engine-oniguruma@3.23.0': dependencies: '@shikijs/types': 3.23.0 @@ -6853,8 +6994,6 @@ snapshots: tslib: 2.8.1 optional: true - '@types/ansi-html@0.0.0': {} - '@types/argparse@1.0.38': optional: true @@ -6863,10 +7002,6 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/debug@4.1.13': - dependencies: - '@types/ms': 2.1.0 - '@types/deep-eql@4.0.2': {} '@types/eslint-scope@3.7.7': @@ -6883,7 +7018,8 @@ snapshots: '@types/esrecurse@4.3.1': {} - '@types/estree@1.0.8': {} + '@types/estree@1.0.8': + optional: true '@types/estree@1.0.9': {} @@ -6901,9 +7037,7 @@ snapshots: '@types/json5@0.0.29': {} - '@types/ms@2.1.0': {} - - '@types/node@26.0.1': + '@types/node@26.1.2': dependencies: undici-types: 8.3.0 @@ -6913,7 +7047,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 '@types/yargs-parser@21.0.3': {} @@ -6921,15 +7055,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/type-utils': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.65.0 - eslint: 10.6.0(jiti@2.7.0) + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/type-utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.68.0 + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -6937,57 +7071,57 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.65.0 - debug: 4.4.3 - eslint: 10.6.0(jiti@2.7.0) + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.68.0 + debug: 4.4.3(supports-color@10.2.2) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.65.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.68.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) - '@typescript-eslint/types': 8.65.0 - debug: 4.4.3 + '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@6.0.3) + '@typescript-eslint/types': 8.68.0 + debug: 4.4.3(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.65.0': + '@typescript-eslint/scope-manager@8.68.0': dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/visitor-keys': 8.68.0 - '@typescript-eslint/tsconfig-utils@8.65.0(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.68.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - debug: 4.4.3 - eslint: 10.6.0(jiti@2.7.0) + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + debug: 4.4.3(supports-color@10.2.2) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.65.0': {} + '@typescript-eslint/types@8.68.0': {} - '@typescript-eslint/typescript-estree@8.65.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.68.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.65.0(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 - debug: 4.4.3 + '@typescript-eslint/project-service': 8.68.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@6.0.3) + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/visitor-keys': 8.68.0 + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 @@ -6996,30 +7130,30 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) - eslint: 10.6.0(jiti@2.7.0) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)) + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.65.0': + '@typescript-eslint/visitor-keys@8.68.0': dependencies: - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/types': 8.68.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-basic-ssl@2.3.0(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0))': + '@vitejs/plugin-basic-ssl@2.3.0(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))': dependencies: - vite: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) - '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 ast-v8-to-istanbul: 1.0.5 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -7028,57 +7162,57 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@26.0.1)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.1.2)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.11.6)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) - '@vitest/expect@4.1.10': + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.1.10 + '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) - '@vitest/pretty-format@4.1.10': + '@vitest/pretty-format@4.1.11': dependencies: tinyrainbow: 3.1.1 - '@vitest/runner@4.1.10': + '@vitest/runner@4.1.11': dependencies: - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 pathe: 2.0.3 - '@vitest/snapshot@4.1.10': + '@vitest/snapshot@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.10': {} + '@vitest/spy@4.1.11': {} - '@vitest/ui@4.1.10(vitest@4.1.10)': + '@vitest/ui@4.1.11(vitest@4.1.11)': dependencies: - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 fflate: 0.8.3 flatted: 3.4.3 pathe: 2.0.3 sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@26.0.1)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.1.2)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.11.6)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) - '@vitest/utils@4.1.10': + '@vitest/utils@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 + '@vitest/pretty-format': 4.1.11 convert-source-map: 2.0.0 tinyrainbow: 3.1.1 @@ -7088,11 +7222,13 @@ snapshots: '@volar/source-map@2.4.28': {} - '@volar/typescript@2.4.28': + '@volar/typescript@2.4.28(typescript@6.0.3)': dependencies: '@volar/language-core': 2.4.28 path-browserify: 1.0.1 vscode-uri: 3.1.0 + optionalDependencies: + typescript: 6.0.3 '@webassemblyjs/ast@1.14.1': dependencies: @@ -7201,20 +7337,17 @@ snapshots: acorn: 8.18.0 optional: true - acorn-jsx@5.3.2(acorn@8.17.0): + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: - acorn: 8.17.0 - - acorn@8.16.0: {} + acorn: 8.18.0 acorn@8.17.0: {} - acorn@8.18.0: - optional: true + acorn@8.18.0: {} - agent-base@6.0.2: + agent-base@6.0.2(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -7250,7 +7383,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 optional: true @@ -7258,7 +7391,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -7266,8 +7399,6 @@ snapshots: dependencies: environment: 1.1.0 - ansi-html@0.0.9: {} - ansi-regex@6.2.2: {} ansi-styles@6.2.3: {} @@ -7373,11 +7504,11 @@ snapshots: axe-core@4.11.4: {} - axios@1.19.0: + axios@1.20.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - follow-redirects: 1.16.0 + follow-redirects: 1.16.0(debug@4.4.3(supports-color@10.2.2)) form-data: 4.0.6 - https-proxy-agent: 5.0.1 + https-proxy-agent: 5.0.1(supports-color@10.2.2) proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -7389,7 +7520,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.7: {} + baseline-browser-mapping@2.11.11: {} beasties@0.4.3: dependencies: @@ -7409,11 +7540,11 @@ snapshots: binary-extensions@2.3.0: {} - body-parser@2.3.0: + body-parser@2.3.0(supports-color@10.2.2): dependencies: bytes: 3.1.2 content-type: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 @@ -7425,16 +7556,12 @@ snapshots: boolbase@1.0.0: {} - brace-expansion@1.1.15: + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.7: - dependencies: - balanced-match: 4.0.4 - - brace-expansion@5.0.8: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -7444,9 +7571,9 @@ snapshots: browserslist@4.28.7: dependencies: - baseline-browser-mapping: 2.11.7 + baseline-browser-mapping: 2.11.11 caniuse-lite: 1.0.30001806 - electron-to-chromium: 1.5.398 + electron-to-chromium: 1.5.399 node-releases: 2.0.51 update-browserslist-db: 1.2.3(browserslist@4.28.7) @@ -7454,7 +7581,7 @@ snapshots: buffer-image-size@0.6.4: dependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 bytes@3.1.2: {} @@ -7507,9 +7634,9 @@ snapshots: chrome-trace-event@1.0.4: optional: true - chromium-bidi@17.0.2(devtools-protocol@0.0.1653615): + chromium-bidi@17.0.2(devtools-protocol@0.0.1666840): dependencies: - devtools-protocol: 0.0.1653615 + devtools-protocol: 0.0.1666840 mitt: 3.0.1 zod: 3.25.76 @@ -7521,9 +7648,9 @@ snapshots: cli-spinners@3.4.0: {} - cli-truncate@5.2.0: + cli-truncate@6.1.1: dependencies: - slice-ansi: 8.0.0 + slice-ansi: 9.0.0 string-width: 8.2.2 cli-width@4.1.0: {} @@ -7541,8 +7668,6 @@ snapshots: dependencies: delayed-stream: 1.0.0 - commander@12.1.0: {} - commander@2.20.3: optional: true @@ -7550,7 +7675,7 @@ snapshots: concat-map@0.0.1: {} - concurrently@10.0.4: + concurrently@10.0.5: dependencies: chalk: 5.6.2 rxjs: 7.8.2 @@ -7637,13 +7762,17 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 - debug@3.2.7: + debug@3.2.7(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 decimal.js@10.6.0: {} @@ -7667,7 +7796,7 @@ snapshots: detect-libc@2.1.2: {} - devtools-protocol@0.0.1653615: {} + devtools-protocol@0.0.1666840: {} diff@8.0.4: optional: true @@ -7704,7 +7833,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.398: {} + electron-to-chromium@1.5.399: {} emoji-regex@10.6.0: {} @@ -7840,63 +7969,34 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - esbuild@0.27.2: + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.2 - '@esbuild/android-arm': 0.27.2 - '@esbuild/android-arm64': 0.27.2 - '@esbuild/android-x64': 0.27.2 - '@esbuild/darwin-arm64': 0.27.2 - '@esbuild/darwin-x64': 0.27.2 - '@esbuild/freebsd-arm64': 0.27.2 - '@esbuild/freebsd-x64': 0.27.2 - '@esbuild/linux-arm': 0.27.2 - '@esbuild/linux-arm64': 0.27.2 - '@esbuild/linux-ia32': 0.27.2 - '@esbuild/linux-loong64': 0.27.2 - '@esbuild/linux-mips64el': 0.27.2 - '@esbuild/linux-ppc64': 0.27.2 - '@esbuild/linux-riscv64': 0.27.2 - '@esbuild/linux-s390x': 0.27.2 - '@esbuild/linux-x64': 0.27.2 - '@esbuild/netbsd-arm64': 0.27.2 - '@esbuild/netbsd-x64': 0.27.2 - '@esbuild/openbsd-arm64': 0.27.2 - '@esbuild/openbsd-x64': 0.27.2 - '@esbuild/openharmony-arm64': 0.27.2 - '@esbuild/sunos-x64': 0.27.2 - '@esbuild/win32-arm64': 0.27.2 - '@esbuild/win32-ia32': 0.27.2 - '@esbuild/win32-x64': 0.27.2 - - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 escalade@3.2.0: {} @@ -7906,40 +8006,40 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.6.0(jiti@2.7.0)): + eslint-config-prettier@10.1.8(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)): dependencies: - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) - eslint-import-resolver-node@0.3.10: + eslint-import-resolver-node@0.3.10(supports-color@10.2.2): dependencies: - debug: 3.2.7 + debug: 3.2.7(supports-color@10.2.2) is-core-module: 2.16.2 resolve: 2.0.0-next.7 transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.6.0(jiti@2.7.0)): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - debug: 3.2.7 + debug: 3.2.7(supports-color@10.2.2) optionalDependencies: - '@typescript-eslint/parser': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.6.0(jiti@2.7.0) - eslint-import-resolver-node: 0.3.10 + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) + eslint-import-resolver-node: 0.3.10(supports-color@10.2.2) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 - debug: 3.2.7 + debug: 3.2.7(supports-color@10.2.2) doctrine: 2.1.0 - eslint: 10.6.0(jiti@2.7.0) - eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.6.0(jiti@2.7.0)) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) + eslint-import-resolver-node: 0.3.10(supports-color@10.2.2) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -7951,13 +8051,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@10.6.0(jiti@2.7.0)): + eslint-plugin-jsx-a11y@6.10.2(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -7967,7 +8067,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -7976,18 +8076,18 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@7.1.1(eslint@10.6.0(jiti@2.7.0)): + eslint-plugin-react-hooks@7.1.1(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/parser': 7.29.7 - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) hermes-parser: 0.25.1 zod: 4.4.3 zod-validation-error: 4.0.2(zod@4.4.3) transitivePeerDependencies: - supports-color - eslint-plugin-react@7.37.5(eslint@10.6.0(jiti@2.7.0)): + eslint-plugin-react@7.37.5(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -7995,7 +8095,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.3.2 - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) estraverse: 5.3.0 hasown: 2.0.4 jsx-ast-utils: 3.3.5 @@ -8009,11 +8109,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)): + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)): dependencies: - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) eslint-scope@5.1.1: dependencies: @@ -8032,12 +8132,12 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.6.0(jiti@2.7.0): + eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 - '@eslint/config-helpers': 0.6.0 + '@eslint/config-array': 0.23.5(supports-color@10.2.2) + '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.2 '@humanfs/node': 0.16.8 @@ -8046,7 +8146,7 @@ snapshots: '@types/estree': 1.0.9 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -8061,7 +8161,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.5 + minimatch: 10.2.6 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -8071,8 +8171,8 @@ snapshots: espree@11.2.0: dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 5.0.1 esquery@1.7.0: @@ -8098,8 +8198,6 @@ snapshots: etag@1.8.1: {} - eventemitter3@5.0.4: {} - events@3.3.0: optional: true @@ -8111,28 +8209,28 @@ snapshots: expect-type@1.4.0: {} - express-rate-limit@8.6.1(express@5.2.1): + express-rate-limit@8.6.1(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - debug: 4.4.3 - express: 5.2.1 - ip-address: 10.3.1 + debug: 4.4.3(supports-color@10.2.2) + express: 5.2.1(supports-color@10.2.2) + ip-address: 10.4.0 transitivePeerDependencies: - supports-color - express@5.2.1: + express@5.2.1(supports-color@10.2.2): dependencies: accepts: 2.0.0 - body-parser: 2.3.0 + body-parser: 2.3.0(supports-color@10.2.2) content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1 + finalhandler: 2.1.1(supports-color@10.2.2) fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -8143,9 +8241,9 @@ snapshots: proxy-addr: 2.0.7 qs: 6.15.3 range-parser: 1.3.0 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 + router: 2.2.0(supports-color@10.2.2) + send: 1.2.1(supports-color@10.2.2) + serve-static: 2.2.1(supports-color@10.2.2) statuses: 2.0.2 type-is: 2.1.0 vary: 1.1.2 @@ -8166,16 +8264,12 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.4: {} + fast-uri@3.1.5: {} fast-wrap-ansi@0.2.2: dependencies: fast-string-width: 3.0.2 - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -8195,9 +8289,9 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@2.1.1: + finalhandler@2.1.1(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -8218,7 +8312,9 @@ snapshots: flatted@3.4.3: {} - follow-redirects@1.16.0: {} + follow-redirects@1.16.0(debug@4.4.3(supports-color@10.2.2)): + optionalDependencies: + debug: 4.4.3(supports-color@10.2.2) for-each@0.3.5: dependencies: @@ -8311,7 +8407,7 @@ snapshots: glob-to-regexp@0.4.1: optional: true - globals@17.8.0: {} + globals@17.11.0: {} globalthis@1.0.4: dependencies: @@ -8322,15 +8418,15 @@ snapshots: graceful-fs@4.2.11: {} - happy-dom@20.11.1: + happy-dom@20.11.6: dependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 buffer-image-size: 0.6.4 entities: 7.0.1 whatwg-mimetype: 3.0.0 - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -8367,7 +8463,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.12.32: {} + hono@4.12.34: {} hosted-git-info@10.1.1: dependencies: @@ -8396,17 +8492,17 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - https-proxy-agent@5.0.1: + https-proxy-agent@5.0.1(supports-color@10.2.2): dependencies: - agent-base: 6.0.2 - debug: 4.4.3 + agent-base: 6.0.2(supports-color@10.2.2) + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color - https-proxy-agent@9.1.0: + https-proxy-agent@9.1.0(supports-color@10.2.2): dependencies: agent-base: 9.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) proxy-agent-negotiate: 1.1.0 transitivePeerDependencies: - kerberos @@ -8445,7 +8541,7 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.0 - ip-address@10.3.1: {} + ip-address@10.4.0: {} ipaddr.js@1.9.1: {} @@ -8582,9 +8678,9 @@ snapshots: istanbul-lib-coverage@3.2.2: {} - istanbul-lib-instrument@6.0.3: + istanbul-lib-instrument@6.0.3(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/parser': 7.29.7 '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 @@ -8615,7 +8711,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 merge-stream: 2.0.0 supports-color: 8.1.1 optional: true @@ -8625,7 +8721,7 @@ snapshots: jju@1.4.0: optional: true - jose@6.2.5: {} + jose@6.2.8: {} js-tokens@10.0.0: {} @@ -8675,11 +8771,11 @@ snapshots: json5@2.2.3: {} - jsonc-eslint-parser@3.1.0: + jsonc-eslint-parser@3.3.0: dependencies: - acorn: 8.16.0 + acorn: 8.18.0 eslint-visitor-keys: 5.0.1 - semver: 7.8.1 + verkit: 0.3.2 jsonc-parser@3.3.1: {} @@ -8782,12 +8878,10 @@ snapshots: dependencies: uc.micro: 2.1.0 - listr2@10.2.2: + listr2@11.0.0: dependencies: - cli-truncate: 5.2.0 - eventemitter3: 5.0.4 - log-update: 6.1.0 - rfdc: 1.4.1 + cli-truncate: 6.1.1 + log-update: 8.0.0 wrap-ansi: 10.0.0 lmdb@3.5.6: @@ -8829,13 +8923,14 @@ snapshots: is-unicode-supported: 2.1.0 yoctocolors: 2.2.0 - log-update@6.1.0: + log-update@8.0.0: dependencies: ansi-escapes: 7.3.0 cli-cursor: 5.0.0 - slice-ansi: 7.1.2 + slice-ansi: 9.0.0 + string-width: 8.2.2 strip-ansi: 7.2.0 - wrap-ansi: 9.0.2 + wrap-ansi: 10.0.0 loose-envify@1.4.0: dependencies: @@ -8865,7 +8960,7 @@ snapshots: magicast@0.5.3: dependencies: '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 source-map-js: 1.2.1 make-dir@2.1.0: @@ -8919,15 +9014,15 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.9 minimatch@10.2.6: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.15 + brace-expansion: 1.1.18 minimist@1.2.8: {} @@ -8940,7 +9035,7 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 - modern-tar@0.7.7: {} + modern-tar@0.8.4: {} mrmime@2.0.1: {} @@ -8967,6 +9062,8 @@ snapshots: nanoid@3.3.16: {} + nanoid@3.3.18: {} + natural-compare@1.4.0: {} needle@3.5.0: @@ -9103,32 +9200,30 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxc-parser@0.140.0: + oxc-parser@0.142.0: dependencies: - '@oxc-project/types': 0.140.0 + '@oxc-project/types': 0.142.0 optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.140.0 - '@oxc-parser/binding-android-arm64': 0.140.0 - '@oxc-parser/binding-darwin-arm64': 0.140.0 - '@oxc-parser/binding-darwin-x64': 0.140.0 - '@oxc-parser/binding-freebsd-x64': 0.140.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.140.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.140.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.140.0 - '@oxc-parser/binding-linux-arm64-musl': 0.140.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.140.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.140.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.140.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.140.0 - '@oxc-parser/binding-linux-x64-gnu': 0.140.0 - '@oxc-parser/binding-linux-x64-musl': 0.140.0 - '@oxc-parser/binding-openharmony-arm64': 0.140.0 - '@oxc-parser/binding-wasm32-wasi': 0.140.0 - '@oxc-parser/binding-win32-arm64-msvc': 0.140.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.140.0 - '@oxc-parser/binding-win32-x64-msvc': 0.140.0 - - p-defer@4.0.1: {} + '@oxc-parser/binding-android-arm-eabi': 0.142.0 + '@oxc-parser/binding-android-arm64': 0.142.0 + '@oxc-parser/binding-darwin-arm64': 0.142.0 + '@oxc-parser/binding-darwin-x64': 0.142.0 + '@oxc-parser/binding-freebsd-x64': 0.142.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.142.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.142.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.142.0 + '@oxc-parser/binding-linux-arm64-musl': 0.142.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.142.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.142.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.142.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.142.0 + '@oxc-parser/binding-linux-x64-gnu': 0.142.0 + '@oxc-parser/binding-linux-x64-musl': 0.142.0 + '@oxc-parser/binding-openharmony-arm64': 0.142.0 + '@oxc-parser/binding-wasm32-wasi': 0.142.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.142.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.142.0 + '@oxc-parser/binding-win32-x64-msvc': 0.142.0 p-limit@3.1.0: dependencies: @@ -9202,11 +9297,11 @@ snapshots: exsolve: 1.1.0 pathe: 2.0.3 - playwright-core@1.61.1: {} + playwright-core@1.62.1: {} - playwright@1.61.1: + playwright@1.62.1: dependencies: - playwright-core: 1.61.1 + playwright-core: 1.62.1 optionalDependencies: fsevents: 2.3.2 @@ -9218,21 +9313,19 @@ snapshots: dependencies: postcss: 8.5.25 - postcss@8.5.16: - dependencies: - nanoid: 3.3.16 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.25: dependencies: nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 - prelude-ls@1.2.1: {} + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 - prettier@3.9.4: {} + prelude-ls@1.2.1: {} prettier@3.9.6: {} @@ -9266,27 +9359,27 @@ snapshots: punycode@2.3.1: {} - puppeteer-core@25.4.0: + puppeteer-core@25.9.0: dependencies: - '@puppeteer/browsers': 3.0.6 - chromium-bidi: 17.0.2(devtools-protocol@0.0.1653615) - devtools-protocol: 0.0.1653615 + '@puppeteer/browsers': 3.2.1 + chromium-bidi: 17.0.2(devtools-protocol@0.0.1666840) + devtools-protocol: 0.0.1666840 typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.2 - ws: 8.21.1 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - proxy-agent - utf-8-validate - yauzl - puppeteer@25.4.0: + puppeteer@25.9.0: dependencies: - '@puppeteer/browsers': 3.0.6 - chromium-bidi: 17.0.2(devtools-protocol@0.0.1653615) - devtools-protocol: 0.0.1653615 + '@puppeteer/browsers': 3.2.1 + chromium-bidi: 17.0.2(devtools-protocol@0.0.1666840) + devtools-protocol: 0.0.1666840 lilconfig: 3.1.3 - puppeteer-core: 25.4.0 + puppeteer-core: 25.9.0 typed-query-selector: 2.12.2 transitivePeerDependencies: - bufferutil @@ -9369,8 +9462,6 @@ snapshots: retry@0.12.0: {} - rfdc@1.4.1: {} - rolldown@1.1.5: dependencies: '@oxc-project/types': 0.139.0 @@ -9413,6 +9504,48 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.0 '@rolldown/binding-win32-x64-msvc': 1.2.0 + rolldown@1.2.2: + dependencies: + '@oxc-project/types': 0.142.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.2 + '@rolldown/binding-darwin-arm64': 1.2.2 + '@rolldown/binding-darwin-x64': 1.2.2 + '@rolldown/binding-freebsd-x64': 1.2.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.2 + '@rolldown/binding-linux-arm64-gnu': 1.2.2 + '@rolldown/binding-linux-arm64-musl': 1.2.2 + '@rolldown/binding-linux-ppc64-gnu': 1.2.2 + '@rolldown/binding-linux-s390x-gnu': 1.2.2 + '@rolldown/binding-linux-x64-gnu': 1.2.2 + '@rolldown/binding-linux-x64-musl': 1.2.2 + '@rolldown/binding-openharmony-arm64': 1.2.2 + '@rolldown/binding-win32-arm64-msvc': 1.2.2 + '@rolldown/binding-win32-x64-msvc': 1.2.2 + optional: true + + rolldown@1.2.6: + dependencies: + '@oxc-project/types': 0.147.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.6 + '@rolldown/binding-android-arm64': 1.2.6 + '@rolldown/binding-darwin-arm64': 1.2.6 + '@rolldown/binding-darwin-x64': 1.2.6 + '@rolldown/binding-freebsd-x64': 1.2.6 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.6 + '@rolldown/binding-linux-arm64-gnu': 1.2.6 + '@rolldown/binding-linux-arm64-musl': 1.2.6 + '@rolldown/binding-linux-ppc64-gnu': 1.2.6 + '@rolldown/binding-linux-s390x-gnu': 1.2.6 + '@rolldown/binding-linux-x64-gnu': 1.2.6 + '@rolldown/binding-linux-x64-musl': 1.2.6 + '@rolldown/binding-openharmony-arm64': 1.2.6 + '@rolldown/binding-win32-arm64-msvc': 1.2.6 + '@rolldown/binding-win32-x64-msvc': 1.2.6 + rollup@4.60.4: dependencies: '@types/estree': 1.0.8 @@ -9443,10 +9576,11 @@ snapshots: '@rollup/rollup-win32-x64-gnu': 4.60.4 '@rollup/rollup-win32-x64-msvc': 4.60.4 fsevents: 2.3.3 + optional: true - router@2.2.0: + router@2.2.0(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -9609,13 +9743,11 @@ snapshots: lru-cache: 6.0.0 optional: true - semver@7.8.1: {} - semver@7.8.5: {} - send@1.2.1: + send@1.2.1(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -9629,12 +9761,12 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@2.2.1: + serve-static@2.2.1(supports-color@10.2.2): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1 + send: 1.2.1(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -9730,12 +9862,7 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 - slice-ansi@7.1.2: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - - slice-ansi@8.0.0: + slice-ansi@9.0.0: dependencies: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 @@ -9866,15 +9993,16 @@ snapshots: tapable@2.3.3: optional: true - terser-webpack-plugin@5.6.1(esbuild@0.28.1)(postcss@8.5.25)(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.25)): + terser-webpack-plugin@5.6.1(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25)(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.46.2 - webpack: 5.106.2(esbuild@0.28.1)(postcss@8.5.25) + webpack: 5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25) optionalDependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 + lightningcss: 1.33.0 postcss: 8.5.25 optional: true @@ -9936,9 +10064,9 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.1: + tsx@4.23.12: dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 @@ -9996,13 +10124,13 @@ snapshots: typescript: 6.0.3 yaml: 2.9.0 - typescript-eslint@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.6.0(jiti@2.7.0) + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -10035,25 +10163,25 @@ snapshots: unpipe@1.0.0: {} - unplugin-dts@1.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.0.1))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.60.4)(typescript@6.0.3)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.25)): + unplugin-dts@1.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.1.2))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.2)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.60.4) - '@volar/typescript': 2.4.28 + '@volar/typescript': 2.4.28(typescript@6.0.3) compare-versions: 6.1.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) kolorist: 1.8.0 local-pkg: 1.2.1 magic-string: 0.30.21 typescript: 6.0.3 unplugin: 2.3.11 optionalDependencies: - '@microsoft/api-extractor': 7.56.0(@types/node@26.0.1) + '@microsoft/api-extractor': 7.56.0(@types/node@26.1.2) '@rspack/core': 1.6.8(@swc/helpers@0.5.18) - esbuild: 0.28.1 - rolldown: 1.2.0 + esbuild: 0.28.2 + rolldown: 1.2.2 rollup: 4.60.4 - vite: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) - webpack: 5.106.2(esbuild@0.28.1)(postcss@8.5.25) + vite: 8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) + webpack: 5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25) transitivePeerDependencies: - supports-color @@ -10081,26 +10209,22 @@ snapshots: vary@1.1.2: {} - vite-live-preview@0.3.2(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(less@4.6.4)(lightningcss@1.33.0)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)): + verkit@0.3.2: {} + + vite-live-preview@0.4.0(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)): dependencies: - '@commander-js/extra-typings': 12.1.0(commander@12.1.0) - '@types/ansi-html': 0.0.0 - '@types/debug': 4.1.13 + '@seahax/deep-copy': 0.1.0 + '@seahax/semaphore': 0.5.1 '@types/ws': 8.18.1 - ansi-html: 0.0.9 - chalk: 5.6.2 - commander: 12.1.0 - debug: 4.4.3 escape-goat: 4.0.0 - p-defer: 4.0.1 - vite: 7.3.5(@types/node@26.0.1)(jiti@2.7.0)(less@4.6.4)(lightningcss@1.33.0)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) - ws: 8.21.0 + strip-ansi: 7.2.0 + vite: 8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) + ws: 8.21.1 transitivePeerDependencies: - bufferutil - - supports-color - utf-8-validate - vite-plugin-checker@0.14.5(eslint@10.6.0(jiti@2.7.0))(optionator@0.9.4)(typescript@6.0.3)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)): + vite-plugin-checker@0.14.5(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@6.0.3)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@babel/code-frame': 7.29.7 chokidar: 5.0.0 @@ -10109,19 +10233,19 @@ snapshots: picomatch: 4.0.4 proper-lockfile: 4.1.2 tiny-invariant: 1.3.3 - vite: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) optionalDependencies: - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) optionator: 0.9.4 typescript: 6.0.3 - vite-plugin-dts@5.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.0.1))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.60.4)(typescript@6.0.3)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.25)): + vite-plugin-dts@5.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.1.2))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.2)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: - unplugin-dts: 1.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.0.1))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.60.4)(typescript@6.0.3)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.25)) + unplugin-dts: 1.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.1.2))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.2)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25)) optionalDependencies: - '@microsoft/api-extractor': 7.56.0(@types/node@26.0.1) + '@microsoft/api-extractor': 7.56.0(@types/node@26.1.2) rollup: 4.60.4 - vite: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: - '@rspack/core' - '@vue/language-core' @@ -10131,35 +10255,15 @@ snapshots: - typescript - webpack - vite-plugin-static-copy@4.1.1(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)): + vite-plugin-static-copy@4.1.1(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)): dependencies: chokidar: 3.6.0 p-map: 7.0.4 picocolors: 1.1.1 tinyglobby: 0.2.17 - vite: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) - vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(less@4.6.4)(lightningcss@1.33.0)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0): - dependencies: - esbuild: 0.27.2 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.16 - rollup: 4.60.4 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 26.0.1 - fsevents: 2.3.3 - jiti: 2.7.0 - less: 4.6.4 - lightningcss: 1.33.0 - sass: 1.101.0 - sass-embedded: 1.97.3 - terser: 5.46.2 - tsx: 4.23.1 - yaml: 2.9.0 - - vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0): + vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -10167,26 +10271,45 @@ snapshots: rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.0.1 - esbuild: 0.28.1 + '@types/node': 26.1.2 + esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 less: 4.6.4 sass: 1.101.0 sass-embedded: 1.97.3 terser: 5.46.2 - tsx: 4.23.1 + tsx: 4.23.12 yaml: 2.9.0 - vitest@4.1.10(@types/node@26.0.1)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)): + vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0): dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.6 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.2 + esbuild: 0.28.2 + fsevents: 2.3.3 + jiti: 2.7.0 + less: 4.6.4 + sass: 1.101.0 + sass-embedded: 1.97.3 + terser: 5.46.2 + tsx: 4.23.12 + yaml: 2.9.0 + + vitest@4.1.11(@types/node@26.1.2)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.11.6)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 es-module-lexer: 2.3.1 expect-type: 1.4.0 magic-string: 0.30.21 @@ -10198,13 +10321,13 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.0.1 - '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) - '@vitest/ui': 4.1.10(vitest@4.1.10) - happy-dom: 20.11.1 + '@types/node': 26.1.2 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + '@vitest/ui': 4.1.11(vitest@4.1.11) + happy-dom: 20.11.6 jsdom: 30.0.1 transitivePeerDependencies: - msw @@ -10231,7 +10354,7 @@ snapshots: webpack-virtual-modules@0.6.2: {} - webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.25): + webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.9 @@ -10254,7 +10377,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 - terser-webpack-plugin: 5.6.1(esbuild@0.28.1)(postcss@8.5.25)(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.25)) + terser-webpack-plugin: 5.6.1(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25)(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25)) watchpack: 2.5.2 webpack-sources: 3.5.1 transitivePeerDependencies: @@ -10358,10 +10481,10 @@ snapshots: wrappy@1.0.2: {} - ws@8.21.0: {} - ws@8.21.1: {} + ws@8.21.3: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} diff --git a/plugins/pnpm-workspace.yaml b/plugins/pnpm-workspace.yaml index ce14894b25..a0099791fc 100644 --- a/plugins/pnpm-workspace.yaml +++ b/plugins/pnpm-workspace.yaml @@ -20,10 +20,15 @@ minimumReleaseAgeExclude: - esbuild@0.28.1 - '@babel/core@7.29.1' - undici@7.28.0 + - brace-expansion@1.1.16 || 1.1.17 || 1.1.18 || 5.0.8 || 5.0.9 + - '@hono/node-server@2.0.5' + - fast-uri@3.1.5 overrides: '@babel/core@<=7.29.0': ^7.29.1 + '@hono/node-server@<2.0.5': ^2.0.5 ajv@>=7.0.0-alpha.0 <8.18.0: ^8.18.0 + brace-expansion@>=4.0.0 <5.0.9: ^5.0.9 lodash@<=4.17.23: ^4.17.24 lodash@>=4.0.0 <=4.17.23: ^4.17.24 minimatch@>=10.0.0 <10.2.1: ^10.2.1 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da166d95ba..008bd049b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,8 +240,8 @@ packages: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} - brace-expansion@5.0.8: - resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} bundle-name@4.1.0: @@ -893,7 +893,7 @@ snapshots: transitivePeerDependencies: - supports-color - brace-expansion@5.0.8: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -1209,7 +1209,7 @@ snapshots: minimatch@10.2.6: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minipass@7.1.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9e59a4df9a..7a28fcc29c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ allowBuilds: minimumReleaseAgeExclude: - playwright-core@1.62.1 - playwright@1.62.1 + - brace-expansion@5.0.9 overrides: "playwright@>=1.61.1 <2.0.0-0": "1.62.1" diff --git a/render-wasm/Cargo.lock b/render-wasm/Cargo.lock index 5d749143fd..05fbd20e6a 100644 --- a/render-wasm/Cargo.lock +++ b/render-wasm/Cargo.lock @@ -99,6 +99,17 @@ dependencies = [ "libloading", ] +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "crc32fast" version = "1.4.2" @@ -114,6 +125,12 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.1" @@ -130,6 +147,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "filetime" version = "0.2.25" @@ -222,6 +245,18 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + [[package]] name = "itertools" version = "0.12.1" @@ -426,6 +461,7 @@ dependencies = [ "gl", "glam", "indexmap", + "insta", "macros", "skia-safe", "thiserror", @@ -519,6 +555,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "skia-bindings" version = "0.93.1" @@ -582,6 +624,19 @@ dependencies = [ "xattr", ] +[[package]] +name = "tempfile" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -721,6 +776,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.52.0" @@ -739,6 +800,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml index 15f8f39335..3c467c1fa2 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -37,8 +37,15 @@ skia-safe = { version = "0.93.1", default-features = false, features = [ thiserror = "2.0.18" uuid = { version = "1.11.0", features = ["v4", "js"] } +[dev-dependencies] +insta = "1" + [profile.release] opt-level = 3 lto = "fat" strip = true codegen-units = 1 + +[profile.size] +inherits = "release" +opt-level = "z" diff --git a/render-wasm/README.md b/render-wasm/README.md index b1010bb70c..4ed4830bcd 100644 --- a/render-wasm/README.md +++ b/render-wasm/README.md @@ -29,6 +29,34 @@ You can also use `./watch` to run the build on every change. The build script will compile the project and copy the `.js` and `.wasm` files to their correct location within the frontend app. +### Render targets + +The same Rust source produces two artifacts, which differ only in compiler +options: + +| Target | Tuned for | Cargo profile | Consumed by | +| ---------- | --------- | ----------------- | ------------------------------ | +| `frontend` | speed | `release` (`-O3`) | `frontend/resources/public/js` | +| `export` | size | `size` (`-Oz`) | `exporter/resources/wasm` | + +```sh +./build # both targets, frontend first +./build frontend # workspace / viewer renderer +./build export # headless exporter renderer +``` + +`./watch` still follows a single target (`frontend` unless you pass one), +since watching both would rebuild twice on every keystroke. + +Each target keeps its own `CARGO_TARGET_DIR` (`target/<target>`), so switching +between them does not invalidate the other's cache. Set `BUILD_MODE=release` +(or `NODE_ENV=production`) for an optimized build; the default is `debug`. + +Each target writes its own generated `shared.js` (the enum discriminants the +CLJS side compiles against) next to the code that imports it — respectively +`frontend/src/app/render_wasm/api/shared.js` and +`exporter/src/app/wasm/shared.js`. Neither build writes to the other's paths. + ![Architecture overview](docs/images/architecture_schema.png) diff --git a/render-wasm/_build_env b/render-wasm/_build_env index 0b506e415d..e034580dfd 100644 --- a/render-wasm/_build_env +++ b/render-wasm/_build_env @@ -1,15 +1,25 @@ #!/usr/bin/env bash export VERSION_TAG=${VERSION:-develop}; +export RENDER_TARGET="${RENDER_TARGET:-${1:-frontend}}"; + +case "$RENDER_TARGET" in + frontend|export) ;; + *) + echo "ERROR: unknown render target '$RENDER_TARGET' (expected 'frontend' or 'export')" >&2; + exit 1; + ;; +esac if [ "$NODE_ENV" = "production" ]; then export BUILD_MODE="release"; else - export BUILD_MODE=${1:-debug}; + export BUILD_MODE=${BUILD_MODE:-debug}; fi export BUILD_NAME="${BUILD_NAME:-render-wasm}" export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"}; +export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-"target/$RENDER_TARGET"}; export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"} # 256 MB of initial heap to perform less @@ -51,9 +61,21 @@ export EM_CACHE="/tmp/emsdk_cache"; export CARGO_PARAMS="${@:2}"; +export CARGO_PROFILE_DIR="debug"; + if [ "$BUILD_MODE" = "release" ]; then - export CARGO_PARAMS="--release $CARGO_PARAMS" - export EMCC_CFLAGS="-O3 -sASSERTIONS=0 $EMCC_CFLAGS" + case "$RENDER_TARGET" in + frontend) + export CARGO_PARAMS="--release $CARGO_PARAMS"; + export CARGO_PROFILE_DIR="release"; + export EMCC_CFLAGS="-O3 -sASSERTIONS=0 $EMCC_CFLAGS"; + ;; + export) + export CARGO_PARAMS="--profile size $CARGO_PARAMS"; + export CARGO_PROFILE_DIR="size"; + export EMCC_CFLAGS="-Oz -sASSERTIONS=0 $EMCC_CFLAGS"; + ;; + esac else # TODO: Extra parameters that could be good to look into: # -gseparate-dwarf @@ -62,6 +84,12 @@ else export EMCC_CFLAGS="-g -sASSERTIONS=1 -sVERBOSE=1 $EMCC_CFLAGS" fi +export FRONTEND_DEST="../frontend/resources/public/js"; +export EXPORT_DEST="../exporter/resources/wasm"; + +export FRONTEND_SHARED_DEST="../frontend/src/app/render_wasm/api/shared.js"; +export EXPORT_SHARED_DEST="../exporter/src/app/wasm/shared.js"; + function clean { cargo clean; } @@ -78,26 +106,48 @@ function build { function copy_artifacts { DEST=$1; + SRC="$CARGO_TARGET_DIR/$CARGO_BUILD_TARGET/$CARGO_PROFILE_DIR"; mkdir -p $DEST; - cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js $DEST/$BUILD_NAME.js; - cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm $DEST/$BUILD_NAME.wasm; - if [ -f target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm.map ]; then - cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm.map $DEST/$BUILD_NAME.wasm.map; + cp $SRC/render_wasm.js $DEST/$BUILD_NAME.js; + cp $SRC/render_wasm.wasm $DEST/$BUILD_NAME.wasm; + if [ -f $SRC/render_wasm.wasm.map ]; then + cp $SRC/render_wasm.wasm.map $DEST/$BUILD_NAME.wasm.map; fi sed -i "s/render_wasm.wasm/$BUILD_NAME.wasm?version=$VERSION_TAG/g" $DEST/$BUILD_NAME.js; - pnpm exec esbuild target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js \ - --log-level=error \ - --outfile=$DEST/worker/render.js \ - --platform=neutral \ - --format=iife \ - --global-name=WasmModule; + # The worker bundle is a browser concern; the exporter imports the ESM + # module directly under Node. + if [ "$RENDER_TARGET" = "frontend" ]; then + pnpm exec esbuild $SRC/render_wasm.js \ + --log-level=error \ + --outfile=$DEST/worker/render.js \ + --platform=neutral \ + --format=iife \ + --global-name=WasmModule; + fi } function copy_shared_artifact { - SHARED_FILE=$(find target/wasm32-unknown-emscripten -name render_wasm_shared.js | head -n 1); - cp $SHARED_FILE ../frontend/src/app/render_wasm/api/shared.js; + DEST=$1; + SHARED_FILE=$(find $CARGO_TARGET_DIR/$CARGO_BUILD_TARGET -name render_wasm_shared.js | head -n 1); + + cp $SHARED_FILE $DEST; +} + +# Copies whatever the current RENDER_TARGET produced to where that target's +# consumer reads it. +function copy_target_artifacts { + case "$RENDER_TARGET" in + frontend) + copy_artifacts "$FRONTEND_DEST"; + copy_shared_artifact "$FRONTEND_SHARED_DEST"; + ;; + export) + copy_artifacts "$EXPORT_DEST"; + copy_shared_artifact "$EXPORT_SHARED_DEST"; + ;; + esac } diff --git a/render-wasm/build b/render-wasm/build index 2505ae0cf9..2a832fc3f4 100755 --- a/render-wasm/build +++ b/render-wasm/build @@ -1,8 +1,26 @@ #!/usr/bin/env bash +# Usage: ./build [frontend|export] [extra cargo params...] +# +# With no target, builds both. Set BUILD_MODE=release (or NODE_ENV=production) +# for an optimized build. See `_build_env` for what each target changes. + +_SCRIPT_DIR=$(dirname $0); + +# Each target needs its own `_build_env`, so re-enter per target. +case "${1:-}" in + frontend|export) + ;; + *) + for _target in frontend export; do + "$_SCRIPT_DIR/build" "$_target" "$@" || exit $?; + done + exit 0; + ;; +esac + EMSDK_QUIET=1 . /opt/emsdk/emsdk_env.sh -_SCRIPT_DIR=$(dirname $0); pushd $_SCRIPT_DIR; . ./_build_env @@ -11,8 +29,7 @@ set -ex; setup; build; -copy_artifacts "../frontend/resources/public/js"; -copy_shared_artifact; +copy_target_artifacts; exit $?; diff --git a/render-wasm/docs/rendering_architecture.md b/render-wasm/docs/rendering_architecture.md index 709c882900..9a6a090609 100644 --- a/render-wasm/docs/rendering_architecture.md +++ b/render-wasm/docs/rendering_architecture.md @@ -80,7 +80,9 @@ flowchart TB The client-side WASM export — rendering in the browser through the vector path (`render_shape_pdf` / `render_shape_pixels`) — is wired **only for single exports** (`request-simple-export` in `frontend/.../exports/assets.cljs`), and -only when render-wasm is active and the `:wasm-export` flag is set. +only when render-wasm is active and the `enable-wasm-export` flag is set. The +exporter service reads the same flag to decide whether it can serve the +headless WASM path. **Multiple/batch export** (`request-multiple-export`) always runs **server-side** via the `:export-shapes` command; it merely passes an `:is-wasm` hint so the diff --git a/render-wasm/docs/text_editor.md b/render-wasm/docs/text_editor.md index 8b65fb1f22..21c73c55db 100644 --- a/render-wasm/docs/text_editor.md +++ b/render-wasm/docs/text_editor.md @@ -108,7 +108,7 @@ flowchart TB FFI_Cursor["_text_editor_set_cursor_from_point<br/>_text_editor_move_cursor<br/>_text_editor_select_all"] FFI_Edit["_text_editor_insert_text<br/>_text_editor_delete_backward<br/>_text_editor_insert_paragraph"] FFI_Query["_text_editor_export_content<br/>_text_editor_get_selection<br/>_text_editor_poll_event"] - FFI_Render["_text_editor_render_overlay<br/>_text_editor_update_blink"] + FFI_Render["_text_editor_render_caret<br/>_text_editor_update_blink"] end subgraph Rust["Rust Layer"] diff --git a/render-wasm/lint b/render-wasm/lint index e94145189a..4ed7d6826d 100755 --- a/render-wasm/lint +++ b/render-wasm/lint @@ -8,7 +8,7 @@ if [[ "$1" == "--debug" ]]; then set -x fi -. ./_build_env +. ./_build_env frontend export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"}; export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"} diff --git a/render-wasm/package.json b/render-wasm/package.json index 1a86f5737b..cdbe651e3d 100644 --- a/render-wasm/package.json +++ b/render-wasm/package.json @@ -4,14 +4,14 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" }, "type": "module", "devDependencies": { - "@types/node": "^26.0.1", - "esbuild": "^0.28.1" + "@types/node": "^26.3.0", + "esbuild": "^0.28.2" } } diff --git a/render-wasm/pnpm-lock.yaml b/render-wasm/pnpm-lock.yaml index 5b1cc35622..c06712b52e 100644 --- a/render-wasm/pnpm-lock.yaml +++ b/render-wasm/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- lockfileVersion: '9.0' settings: @@ -9,175 +110,175 @@ importers: .: devDependencies: '@types/node': - specifier: ^26.0.1 - version: 26.0.1 + specifier: ^26.3.0 + version: 26.3.0 esbuild: - specifier: ^0.28.1 - version: 0.28.1 + specifier: ^0.28.2 + version: 0.28.2 packages: - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@types/node@26.0.1': - resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/node@26.3.0': + resolution: {integrity: sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -186,115 +287,115 @@ packages: snapshots: - '@esbuild/aix-ppc64@0.28.1': + '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.28.1': + '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.28.1': + '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.28.1': + '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.28.1': + '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.28.1': + '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.28.1': + '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.28.1': + '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.28.1': + '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.28.1': + '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.28.1': + '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.28.1': + '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.28.1': + '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.28.1': + '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.28.1': + '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.28.1': + '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.28.1': + '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.28.1': + '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.28.1': + '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.28.1': + '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.28.1': + '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.28.1': + '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.28.1': + '@esbuild/win32-x64@0.28.2': optional: true - '@types/node@26.0.1': + '@types/node@26.3.0': dependencies: undici-types: 8.3.0 - esbuild@0.28.1: + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 undici-types@8.3.0: {} diff --git a/render-wasm/preview-snapshots b/render-wasm/preview-snapshots new file mode 100755 index 0000000000..43a23421ca --- /dev/null +++ b/render-wasm/preview-snapshots @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# +# Visual review helper for the SVG exporter's `insta` snapshots. +# +# This script renders the insta snapshots into an HTML gallery +# so you can judge whether a change is *visually* valid +# before accepting it. +# +# Usage: +# ./preview-snapshots # build the gallery and print its path +# ./preview-snapshots --open # also open it in the default browser +# +# Text snapshots reference `fonts/sourcesanspro-regular.ttf`; this script copies +# the bundled font into `target/svg-preview/fonts/` so the gallery renders text. +# +# When a test produced a pending change there will be a `*.snap.new` next to the +# accepted `*.snap`; the gallery then shows "accepted" vs "new" side by side. +# Once a change looks correct, accept it (rename `*.snap.new` -> `*.snap`, or +# `cargo insta accept`) and re-run the tests. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# `mod tests;` lives as `svg/tests.rs`, so insta writes under `svg/snapshots/`. +SNAP_DIR="$SCRIPT_DIR/src/render/svg/snapshots" +OUT_DIR="$SCRIPT_DIR/target/svg-preview" +OUT="$OUT_DIR/index.html" +FONT_SRC="$SCRIPT_DIR/src/fonts/sourcesanspro-regular.ttf" +FONT_DIR="$OUT_DIR/fonts" + +mkdir -p "$OUT_DIR" +mkdir -p "$FONT_DIR" +cp "$FONT_SRC" "$FONT_DIR/" + +# Prints the SVG body of a snapshot file: everything after the second `---` +# line (the YAML front matter insta writes). +extract_body() { + awk 'd>=2{print} /^---$/{d++}' "$1" +} + +# Embeds a snapshot's SVG via <object> pointing at a standalone .svg file. +# +# Snapshots must NOT be inlined together into one HTML document: each SVG +# restarts its def ids at `clip0`/`blur0`/..., so inlining several in the same +# document makes `url(#clip0)` collide and resolve to the wrong (or empty) clip +# path. Writing each SVG to its own file isolates ids per document. +# We use <object> (not <img>) so linked @font-face rules and other external +# resources resolve inside the SVG document. +svg_counter=0 +read_svg_dims() { + local file="$1" + local tag + tag=$(grep -m1 '<svg' "$file" | sed 's/>.*//') + SVG_W=$(echo "$tag" | sed -n 's/.*[[:space:]]width="\([^"]*\)".*/\1/p') + SVG_H=$(echo "$tag" | sed -n 's/.*[[:space:]]height="\([^"]*\)".*/\1/p') +} +emit_svg_box() { + local body_file="$OUT_DIR/svg-$svg_counter.svg" + local name + svg_counter=$((svg_counter + 1)) + name="$(basename "$body_file")" + extract_body "$1" > "$body_file" + read_svg_dims "$body_file" + echo "<div class=\"cb\"><object data=\"$name\" type=\"image/svg+xml\" width=\"$SVG_W\" height=\"$SVG_H\"></object></div>" +} + +{ + cat <<'HTML' +<!doctype html> +<meta charset="utf-8"> +<title>render-wasm SVG snapshot preview + +

render-wasm SVG snapshot preview

+HTML + + shopt -s nullglob + + for snap in "$SNAP_DIR"/*.snap; do + name="$(basename "$snap" .snap)" + new="$snap.new" + echo "

$name

" + if [ -f "$new" ]; then + echo '

Pending change: review before accepting

' + echo '
' + echo '
accepted (current .snap)
' + emit_svg_box "$snap" + echo '
' + echo '
new (.snap.new)
' + emit_svg_box "$new" + echo '
' + echo '
' + echo '
text diff
'
+            diff -u "$snap" "$new" | sed 's/&/\&/g; s//\>/g' || true
+            echo '
' + else + emit_svg_box "$snap" + fi + echo '
' + done + + # New tests whose snapshot has never been accepted yet. + for new in "$SNAP_DIR"/*.snap.new; do + base="${new%.new}" + [ -f "$base" ] && continue + name="$(basename "$new" .snap.new)" + echo "

$name

" + echo '

new snapshot (no accepted version yet)

' + emit_svg_box "$new" + echo '
' + done +} > "$OUT" + +echo "Wrote $OUT" + +if [ "${1:-}" = "--open" ]; then + xdg-open "$OUT" >/dev/null 2>&1 || open "$OUT" >/dev/null 2>&1 || true +fi diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs index cd0fcf0bbf..b7956075a6 100644 --- a/render-wasm/src/globals.rs +++ b/render-wasm/src/globals.rs @@ -40,11 +40,27 @@ pub(crate) fn get_render_state() -> &'static mut RenderState { } } +#[inline(always)] +pub(crate) fn current_browser() -> u8 { + unsafe { + if DESIGN_STATE.is_null() { + 0 + } else { + (*DESIGN_STATE).current_browser + } + } +} + #[inline(always)] pub(crate) fn has_render_state() -> bool { unsafe { !RENDER_STATE.is_null() } } +#[inline(always)] +pub(crate) fn has_render_resources() -> bool { + unsafe { !RENDER_RESOURCES.is_null() } +} + #[inline(always)] pub(crate) fn get_resources() -> &'static mut RenderResources { unsafe { @@ -104,6 +120,28 @@ macro_rules! with_current_shape { }; } +/// Scoped override of the global render resources pointer for unit tests. +#[cfg(test)] +pub(crate) struct TestRenderResourcesGuard { + prev: *mut RenderResources, +} + +#[cfg(test)] +impl TestRenderResourcesGuard { + pub(crate) fn install(resources: &mut RenderResources) -> Self { + let prev = unsafe { RENDER_RESOURCES }; + unsafe { RENDER_RESOURCES = resources as *mut _ }; + Self { prev } + } +} + +#[cfg(test)] +impl Drop for TestRenderResourcesGuard { + fn drop(&mut self) { + unsafe { RENDER_RESOURCES = self.prev }; + } +} + /// Initializes GPUState. fn gpu_init() { unsafe { diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 2d007d18e4..125f553a0b 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -51,47 +51,6 @@ pub extern "C" fn set_render_options(debug: u32, dpr: f32) -> Result<()> { Ok(()) } -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_viewport_interest_area_threshold( - viewport_interest_area_threshold: i32, -) -> Result<()> { - let render_state = get_render_state(); - render_state.set_viewport_interest_area_threshold(viewport_interest_area_threshold); - Ok(()) -} - -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_max_blocking_time_ms(max_blocking_time_ms: i32) -> Result<()> { - let render_state = get_render_state(); - render_state.set_max_blocking_time_ms(max_blocking_time_ms); - Ok(()) -} - -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_node_batch_threshold(node_batch_threshold: i32) -> Result<()> { - let render_state = get_render_state(); - render_state.set_node_batch_threshold(node_batch_threshold); - Ok(()) -} - -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_blur_downscale_threshold(blur_downscale_threshold: f32) -> Result<()> { - let render_state = get_render_state(); - render_state.set_blur_downscale_threshold(blur_downscale_threshold); - Ok(()) -} - -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_antialias_threshold(threshold: f32) -> Result<()> { - get_render_state().set_antialias_threshold(threshold); - Ok(()) -} - #[no_mangle] #[wasm_error] pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> { @@ -469,16 +428,6 @@ pub extern "C" fn has_shape(a: u32, b: u32, c: u32, d: u32) -> Result { }); } -#[no_mangle] -#[wasm_error] -pub extern "C" fn touch_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { - with_state!(state, { - let shape_id = uuid_from_u32_quartet(a, b, c, d); - state.touch_shape(shape_id); - }); - Ok(()) -} - #[no_mangle] #[wasm_error] pub extern "C" fn set_parent(a: u32, b: u32, c: u32, d: u32) -> Result<()> { @@ -541,44 +490,9 @@ pub extern "C" fn set_shape_transform( Ok(()) } -#[no_mangle] -#[wasm_error] -pub extern "C" fn add_shape_child(a: u32, b: u32, c: u32, d: u32) -> Result<()> { - with_current_shape_mut!(state, |shape: &mut Shape| { - let id = uuid_from_u32_quartet(a, b, c, d); - shape.add_child(id); - }); - Ok(()) -} - fn set_children_set(entries: Vec) -> Result<()> { - let mut deleted = Vec::new(); - let mut parent_id = None; - - with_current_shape_mut!(state, |shape: &mut Shape| { - parent_id = Some(shape.id); - (_, deleted) = shape.compute_children_differences(&entries); - shape.children = entries.clone(); - - for id in entries { - state.touch_shape(id); - if let Some(children_shape) = state.shapes.get_mut(&id) { - children_shape.set_deleted(false); - } - } - }); - with_state!(state, { - let Some(parent_id) = parent_id else { - return Err(Error::RecoverableError( - "set_children_set: Parent ID not found".to_string(), - )); - }; - - for id in deleted { - state.delete_shape_children(parent_id, id); - state.touch_shape(id); - } + state.set_current_shape_children(entries)?; }); Ok(()) } @@ -591,124 +505,6 @@ pub extern "C" fn set_children_0() -> Result<()> { Ok(()) } -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_children_1(a1: u32, b1: u32, c1: u32, d1: u32) -> Result<()> { - let entries = vec![uuid_from_u32_quartet(a1, b1, c1, d1)]; - set_children_set(entries)?; - Ok(()) -} - -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_children_2( - a1: u32, - b1: u32, - c1: u32, - d1: u32, - a2: u32, - b2: u32, - c2: u32, - d2: u32, -) -> Result<()> { - let entries = vec![ - uuid_from_u32_quartet(a1, b1, c1, d1), - uuid_from_u32_quartet(a2, b2, c2, d2), - ]; - set_children_set(entries)?; - Ok(()) -} - -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_children_3( - a1: u32, - b1: u32, - c1: u32, - d1: u32, - a2: u32, - b2: u32, - c2: u32, - d2: u32, - a3: u32, - b3: u32, - c3: u32, - d3: u32, -) -> Result<()> { - let entries = vec![ - uuid_from_u32_quartet(a1, b1, c1, d1), - uuid_from_u32_quartet(a2, b2, c2, d2), - uuid_from_u32_quartet(a3, b3, c3, d3), - ]; - set_children_set(entries)?; - Ok(()) -} - -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_children_4( - a1: u32, - b1: u32, - c1: u32, - d1: u32, - a2: u32, - b2: u32, - c2: u32, - d2: u32, - a3: u32, - b3: u32, - c3: u32, - d3: u32, - a4: u32, - b4: u32, - c4: u32, - d4: u32, -) -> Result<()> { - let entries = vec![ - uuid_from_u32_quartet(a1, b1, c1, d1), - uuid_from_u32_quartet(a2, b2, c2, d2), - uuid_from_u32_quartet(a3, b3, c3, d3), - uuid_from_u32_quartet(a4, b4, c4, d4), - ]; - set_children_set(entries)?; - Ok(()) -} - -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_children_5( - a1: u32, - b1: u32, - c1: u32, - d1: u32, - a2: u32, - b2: u32, - c2: u32, - d2: u32, - a3: u32, - b3: u32, - c3: u32, - d3: u32, - a4: u32, - b4: u32, - c4: u32, - d4: u32, - a5: u32, - b5: u32, - c5: u32, - d5: u32, -) -> Result<()> { - let entries = vec![ - uuid_from_u32_quartet(a1, b1, c1, d1), - uuid_from_u32_quartet(a2, b2, c2, d2), - uuid_from_u32_quartet(a3, b3, c3, d3), - uuid_from_u32_quartet(a4, b4, c4, d4), - uuid_from_u32_quartet(a5, b5, c5, d5), - ]; - set_children_set(entries)?; - Ok(()) -} - #[no_mangle] #[wasm_error] pub extern "C" fn set_children() -> Result<()> { @@ -743,15 +539,14 @@ pub extern "C" fn is_image_cached( } /// Evicts least-recently-used images until the store retains at most -/// `max_mb` megabytes of image data. Called by the headless exporter between +/// `max_bytes` bytes of image data. Called by the headless exporter between /// requests — never mid-render, so an image can't disappear under a running /// export; evicted images are re-provisioned by later requests that need /// them. Returns the number of evicted images. #[no_mangle] #[wasm_error] -pub extern "C" fn evict_images_to_budget(max_mb: u32) -> Result { - let max_bytes = (max_mb as usize) * 1024 * 1024; - let evicted = get_resources().images.evict_to_budget(max_bytes); +pub extern "C" fn evict_images_to_budget(max_bytes: u32) -> Result { + let evicted = get_resources().images.evict_to_budget(max_bytes as usize); Ok(evicted as u32) } @@ -911,7 +706,7 @@ pub extern "C" fn clean_modifiers() -> Result<()> { // the same tiles for the active modifier set, so the eviction // here is redundant and doubles the per-emission cost. if !prev_modifier_ids.is_empty() && !render_state.options.is_interactive_transform() { - render_state.update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?; + render_state.update_tiles_shapes(&prev_modifier_ids, &state.shapes)?; } }); Ok(()) @@ -1037,6 +832,22 @@ pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32, scale: f32) - }) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn render_shape_svg(a: u32, b: u32, c: u32, d: u32, scale: f32) -> Result<*mut u8> { + let id = uuid_from_u32_quartet(a, b, c, d); + + with_state!(state, { + let data = state.render_shape_svg(&id, scale)?; + + let len = data.len() as u32; + let mut buf = Vec::with_capacity(4 + data.len()); + buf.extend_from_slice(&len.to_le_bytes()); + buf.extend_from_slice(&data); + Ok(mem::write_bytes(buf)) + }) +} + /// Raster image via CPU (no GPU/WebGL). Returns `[len][width][height][bytes]` /// (LE), same layout as `render_shape_pixels`. `format` selects the encoder: /// 0 = PNG, 1 = JPEG, 2 = WEBP (see `RasterFormat`). diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 7ff4423f7a..64767b29bb 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -13,6 +13,7 @@ mod shadows; pub mod shape_renderer; mod strokes; mod surfaces; +pub mod svg; pub mod text; pub mod text_editor; mod ui; @@ -26,15 +27,16 @@ use options::RenderOptions; pub use surfaces::{SurfaceId, Surfaces}; use crate::error::{Error, Result}; +use crate::globals::get_text_editor_state; use crate::math; use crate::shapes::{ - all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, - Stroke, StrokeKind, TextContent, Type, + all_with_ancestors, modifier_changes_text_layout, radius_to_sigma, Blur, BlurType, Corners, + Fill, Shadow, Shape, SolidColor, Stroke, StrokeKind, Type, }; use crate::state::{ShapesPoolMutRef, ShapesPoolRef}; use crate::tiles::{self, PendingTiles, TileRect}; use crate::uuid::Uuid; -use crate::view::Viewbox; +use crate::view::{self, Viewbox}; use crate::wapi; use crate::{get_gpu_state, get_resources, performance}; @@ -49,6 +51,10 @@ pub enum FrameType { None = 0, Partial = 1, Full = 2, + /// Viewport tiles are presented; interest-ring work may still be pending. + /// Frontend should keep requesting frames (like Partial) but may treat the + /// Target as freshly composited (like Full) for overlays. + ViewportReady = 3, } #[allow(dead_code)] @@ -286,6 +292,12 @@ impl FocusMode { } } +fn text_layout_cache_rotation_only(tree: ShapesPoolRef, shape: &Shape) -> bool { + tree.get_raw(&shape.id) + .zip(tree.get_layout_modifier(&shape.id)) + .is_some_and(|(base, modifier)| !modifier_changes_text_layout(base, &modifier)) +} + /* * Sort by z_index descending (higher z renders on top). * The sort is stable so if the values are equal the index for the children @@ -379,6 +391,8 @@ pub(crate) struct RenderState { /// Frame id passed as `base_object` for viewer renders; always traversed. pub viewer_render_root: Option, pub touched_ids: HashSet, + /// Pre-edit extrects for old∪new tile eviction (captured on first touch). + touched_prev_extrects: HashMap, /// Temporary flag used for off-screen passes (drop-shadow masks, filter surfaces, etc.) /// where we must render shapes without inheriting ancestor layer blurs. Toggle it through /// `with_nested_blurs_suppressed` to ensure it's always restored. @@ -413,6 +427,15 @@ pub(crate) struct RenderState { /// a tile before its text glyph uploads complete (blank first/center tile). /// One explicit flush warms the submit path for the rest of the pass. pub tile_atlas_flushed: bool, + /// DropShadows→Current touch once per tile when no shape composites a real + /// shadow. A full skip made flush_and_submit very slow (Skia ops-task + /// ordering); doing it per shape was wasted GPU work. + pub drop_shadows_ops_warmed: bool, + /// Filter-surface snapshots for drop shadows, reused across tiles. + drop_shadow_filter_cache: shadows::DropShadowFilterCache, + /// Visible tiles were already presented this pass; interest-ring fill may + /// still be running. Final Full should not re-present. + pub viewport_presented: bool, } pub struct InteractiveDragCrop { @@ -547,6 +570,9 @@ impl RenderState { pub fn try_new(width: i32, height: i32) -> Result { // This needs to be done once per WebGL context. let sampling_options = get_resources().sampling_options; + let max_dim = get_gpu_state().max_surface_size(); + let width = width.clamp(1, max_dim); + let height = height.clamp(1, max_dim); let surfaces = Surfaces::try_new( (width, height), @@ -587,6 +613,7 @@ impl RenderState { include_filter: None, viewer_render_root: None, touched_ids: HashSet::default(), + touched_prev_extrects: HashMap::default(), ignore_nested_blurs: false, preview_mode: false, export_context: None, @@ -596,6 +623,9 @@ impl RenderState { preserve_target_during_render: false, backbuffer_crop_cache: HashMap::default(), tile_atlas_flushed: false, + drop_shadows_ops_warmed: false, + drop_shadow_filter_cache: shadows::DropShadowFilterCache::new(), + viewport_presented: false, }) } @@ -870,46 +900,22 @@ impl RenderState { pub fn set_dpr(&mut self, dpr: f32) -> Result<()> { // Only when this function returns true (it means the value // was properly changed) the rest of the functions is called. + // Surface/viewbox pixel size is updated by `resize` after the + // canvas backing store is set, so we do not resize here with a + // stale CSS size (that desyncs Skia vs the GL framebuffer). if self.options.set_dpr(dpr) { + self.viewbox.set_dpr(dpr); self.tile_viewbox .set_interest(self.options.dpr_viewport_interest_area_threshold); - self.resize( - self.viewbox.width().floor() as i32, - self.viewbox.height().floor() as i32, - )?; get_resources().fonts.set_scale_debug_font(dpr); - self.viewbox.set_dpr(dpr); self.surfaces.set_dpr(dpr); } Ok(()) } - pub fn set_antialias_threshold(&mut self, value: f32) { - self.options.set_antialias_threshold(value); - } - - pub fn set_viewport_interest_area_threshold(&mut self, value: i32) { - // Only when this function returns true (it means the value - // was changed properly) the tile_viewbox.set_interest is called. - if self.options.set_viewport_interest_area_threshold(value) { - // The TileViewbox stores its own copy of `interest` (set at - // construction). Without propagating, options change wouldn't - // affect pending_tiles generation. - self.tile_viewbox - .set_interest(self.options.dpr_viewport_interest_area_threshold); - } - } - - pub fn set_node_batch_threshold(&mut self, value: i32) { - self.options.set_node_batch_threshold(value); - } - - pub fn set_max_blocking_time_ms(&mut self, value: i32) { - self.options.set_max_blocking_time_ms(value); - } - - pub fn set_blur_downscale_threshold(&mut self, value: f32) { - self.options.set_blur_downscale_threshold(value); + pub fn ensure_tile_atlas_layout(&mut self) { + self.surfaces + .ensure_tile_atlas_layout(self.tile_viewbox.interest_rect.len().max(1) as usize); } pub fn set_background_color(&mut self, color: skia::Color) { @@ -921,19 +927,31 @@ impl RenderState { } pub fn resize(&mut self, width: i32, height: i32) -> Result<()> { - let dpr_width = (width as f32 * self.options.dpr).floor() as i32; - let dpr_height = (height as f32 * self.options.dpr).floor() as i32; + let gpu_state = get_gpu_state(); + let max_dim = gpu_state.max_surface_size(); + let css_w = (width as f32).max(1.0); + let css_h = (height as f32).max(1.0); + let dpr = view::clamp_dpr_for_surface(css_w, css_h, self.options.dpr, max_dim); + let mut dpr_width = ((css_w * dpr).floor() as i32).clamp(1, max_dim); + let mut dpr_height = ((css_h * dpr).floor() as i32).clamp(1, max_dim); + // Prefer the real GL drawing buffer: wrap_backend_render_target + // binds the default framebuffer, whose origin is bottom-left. + if let Some((fb_w, fb_h)) = gpu_state.drawing_buffer_size() { + dpr_width = fb_w.clamp(1, max_dim); + dpr_height = fb_h.clamp(1, max_dim); + } + let effective_dpr = (dpr_width as f32 / css_w).min(dpr_height as f32 / css_h); + if (effective_dpr - self.options.dpr).abs() > f32::EPSILON { + self.set_dpr(effective_dpr)?; + } self.surfaces.resize(dpr_width, dpr_height)?; - self.viewbox.set_wh(width as f32, height as f32); + self.viewbox.set_wh(css_w, css_h); self.tile_viewbox.update(&self.viewbox); + self.ensure_tile_atlas_layout(); Ok(()) } - pub fn flush(&mut self) { - self.surfaces.flush(SurfaceId::Backbuffer); - } - pub fn flush_and_submit(&mut self) { self.surfaces.flush_and_submit(SurfaceId::Target); } @@ -968,11 +986,34 @@ impl RenderState { debug::render(self); } if !self.preview_mode { + self.render_text_editor_overlay(tree); ui::render(self, tree); } debug::render_wasm_label(self); } + /// Drawn on Target before the UI surface is composited, so rulers and guides + /// stay above the selection band + fn render_text_editor_overlay(&mut self, tree: ShapesPoolRef) { + let editor_state = get_text_editor_state(); + let Some(shape_id) = editor_state.active_shape_id else { + return; + }; + let Some(shape) = tree.get(&shape_id) else { + return; + }; + + let viewbox = self.viewbox; + let options = self.options; + text_editor::render_overlay( + self.surfaces.canvas(SurfaceId::Target), + &viewbox, + &options, + editor_state, + shape, + ); + } + /// Renders only the canvas background and UI surface (rulers/frame), without /// rebuilding or drawing any shape tiles. Used to show the viewport frame /// immediately before shape tiles are built (e.g., right after a DPR change). @@ -1079,15 +1120,20 @@ impl RenderState { } let fast_mode = self.options.is_fast_mode(); + // During pan/zoom (fast mode) tiles are rendered without shadows/blur. + // Do not write them into the doc/tile atlases: render_from_cache overlays + // HQ tile textures on the scaled doc-atlas backdrop, and shadowless tiles + // would leave permanent holes until the post-gesture full render. + if fast_mode { + return Ok(()); + } // Decide *now* (at the first real cache blit) whether we need to clear Cache. // This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI), // while still preventing stale pixels from surviving across full-quality renders. - if !fast_mode && !self.cache_cleared_this_render { + if !self.cache_cleared_this_render { self.surfaces.clear_cache(self.background_color); self.cache_cleared_this_render = true; } - // In fast mode the viewport is moving (pan/zoom) so Cache surface - // positions would be wrong — only save to the tile HashMap. let tile_rect = self.get_current_aligned_tile_bounds()?; let current_tile = *self @@ -1103,8 +1149,10 @@ impl RenderState { &self.tile_viewbox, ¤t_tile, &tile_rect, - fast_mode, + false, self.render_area, + self.get_scale(), + self.viewbox.area, ); Ok(()) @@ -1224,11 +1272,16 @@ impl RenderState { } fn get_inherited_drop_shadows(&self) -> Option> { + let scale = self.get_scale(); let drop_shadows: Vec<&Shadow> = self .nested_shadows .iter() .flat_map(|shadows| shadows.iter()) - .filter(|shadow| !shadow.hidden() && shadow.style() == crate::shapes::ShadowStyle::Drop) + .filter(|shadow| { + !shadow.hidden() + && shadow.style() == crate::shapes::ShadowStyle::Drop + && shadow.is_perceptible_at_scale(scale) + }) .collect(); if drop_shadows.is_empty() { @@ -1248,6 +1301,66 @@ impl RenderState { ) } + /// Apply frame clip stack in document space on the given surface bitmask. + /// Caller must already have those surfaces in doc transform (Fills-style + /// scale + tile translation, or Current after the same). Hard (non-AA) + /// clips avoid alpha seams on semi-transparent overflow. + fn apply_clip_stack_to_surfaces( + &mut self, + clips: &ClipStack, + surface_ids: u32, + scale: f32, + debug_fill_surface: Option, + ) { + for (mut bounds, corners, transform) in clips.iter() { + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas().concat(transform); + }); + + // Outset clip by ~0.5 to include edge pixels that + // aliased clip misclassifies as outside (causing artifacts). + let outset = 0.5 / scale; + bounds.outset((outset, outset)); + + // Hard clip edge (antialias = false) to avoid alpha seam when clipping + // semi-transparent content larger than the frame. + if let Some(corners) = corners { + let rrect = RRect::new_rect_radii(bounds, corners); + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas().clip_rrect(rrect, skia::ClipOp::Intersect, false); + }); + } else { + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas().clip_rect(bounds, skia::ClipOp::Intersect, false); + }); + } + + // This renders a red line around clipped + // shapes (frames). + if self.options.is_debug_visible() { + if let Some(fills_surface_id) = debug_fill_surface { + let mut paint = skia::Paint::default(); + paint.set_style(skia::PaintStyle::Stroke); + paint.set_color(skia::Color::from_argb(255, 255, 0, 0)); + paint.set_stroke_width(4.); + self.surfaces + .canvas(fills_surface_id) + .draw_rect(bounds, &paint); + } + } + + // Uncomment to debug the render_position_data + // if let Type::Text(text_content) = &shape.shape_type { + // text::render_position_data(self, fills_surface_id, &shape, text_content); + // } + + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas() + .concat(&transform.invert().unwrap_or(Matrix::default())); + }); + } + } + #[allow(clippy::too_many_arguments)] pub fn render_shape( &mut self, @@ -1262,6 +1375,7 @@ impl RenderState { parent_shadows: Option>, outset: Option, target_surface: SurfaceId, + text_layout_cache_rotation_only: bool, ) -> Result<()> { #[cfg(feature = "stats")] self.stats.count(shape.id); @@ -1271,17 +1385,8 @@ impl RenderState { | innershadows_surface_id as u32 | text_drop_shadows_surface_id as u32; - // Only save canvas state if we have clipping or transforms - // For simple shapes without clipping, skip expensive save/restore - let needs_save = - clip_bounds.is_some() || offset.is_some() || !shape.transform.is_identity(); - - if needs_save { - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas().save(); - }); - } let fast_mode = self.options.is_fast_mode(); + let skip_drop_shadows = self.should_skip_drop_shadows(); // Skip anti-aliasing entirely during fast_mode (interactive // gestures + pan/zoom). AA edge sampling is per-pixel and adds // up across many shapes; reverts to full quality on commit. @@ -1297,29 +1402,65 @@ impl RenderState { && self.nested_blurs.iter().flatten().any(|blur| { !blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0 }); - let can_render_directly = apply_to_current_surface - && clip_bounds.is_none() - && offset.is_none() - && parent_shadows.is_none() - && !shape.needs_layer() + + // Empty non-masked groups paint nothing here (children are separate walker + // nodes). Skip the layered Fills/Strokes path entirely. + if matches!(shape.shape_type, Type::Group(g) if !g.masked) + && shape.fills.is_empty() + && !shape.has_visible_strokes() + && shape.shadows.is_empty() && shape.blur.is_none() && shape.background_blur.is_none() && !has_inherited_blur - && shape.shadows.is_empty() - && shape.transform.is_identity() - && matches!( - shape.shape_type, - Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) - ) - && !(shape.fills.is_empty() && has_nested_fills) - && !shape - .svg_attrs - .as_ref() - .is_some_and(|attrs| attrs.fill_none) + && parent_shadows.is_none() + { + return Ok(()); + } + + // Only perceptible shadows need the layered Fills/Strokes path. Use the + // same footprint LOD as when painting drop and inner shadows. + let scale = self.get_scale(); + let shadows_need_layered = !skip_drop_shadows + && (shape + .drop_shadows_visible() + .any(|s| s.is_perceptible_at_scale_for(scale, shape.is_recursive())) + || shape + .inner_shadows_visible() + .any(|s| s.is_perceptible_at_scale_for(scale, shape.is_recursive()))); + + // Clip is allowed: we apply the same stack on Current after scale+translate. + // Opacity < 1 with SrcOver is OK: render_shape_enter already opened a + // save_layer on Current; painting fills/strokes into that layer matches + // the layered path without Fills/Strokes blits. + // Non-SrcOver blend, frame clip blur, and masked groups stay layered. + // Stroke-only (fills_none) can go direct: empty fills are a no-op and + // strokes paint into Current. Large files need mid-walk GPU drains so + // release builds do not backlog a huge ops buffer in one Partial. + // + // Plain text (no strokes / effects) also paints into Current: span styles + // live in Skia Paragraph TextStyles, so multi-style text is fine. + // Text skips the nested_fills guard because fills are on spans, not + // shape.fills. Strokes stay layered (masking needs save_layers). + let is_direct_geometry = matches!( + shape.shape_type, + Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Frame(_) + ) && !(shape.fills.is_empty() && has_nested_fills); + let is_direct_text = + matches!(shape.shape_type, Type::Text(_)) && !shape.has_visible_strokes(); + let can_render_directly = apply_to_current_surface + && offset.is_none() + && parent_shadows.is_none() + && shape.blend_mode().0 == skia::BlendMode::SrcOver + && !shape.has_frame_clip_layer_blur() + && !matches!(shape.shape_type, Type::Group(g) if g.masked) + && shape.blur.is_none() + && shape.background_blur.is_none() + && !has_inherited_blur + && !shadows_need_layered + && (is_direct_geometry || is_direct_text) && target_surface != SurfaceId::Export; if can_render_directly { - let scale = self.get_scale(); let translation = self .surfaces .get_render_context_translation(self.render_area, scale); @@ -1331,17 +1472,65 @@ impl RenderState { canvas.translate(translation); }); - fills::render(self, shape, &shape.fills, antialias, target_surface, None)?; - // Pass strokes in natural order; stroke merging handles top-most ordering internally. - let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect(); - strokes::render( - self, - shape, - &visible_strokes, - Some(target_surface), - antialias, - outset, - )?; + if let Some(clips) = clip_bounds.as_ref() { + self.apply_clip_stack_to_surfaces(clips, target_surface as u32, scale, None); + } + + if !shape.transform.is_identity() { + let center = shape.center(); + let mut matrix = shape.transform; + matrix.post_translate(center); + matrix.pre_translate(-center); + self.surfaces.apply_mut(target_surface as u32, |s| { + s.canvas().concat(&matrix); + }); + } + + if let Type::Text(stored_text_content) = &shape.shape_type { + self.tile_atlas_flushed = true; + + if !text::try_paint_from_layout_cache( + Some(self), + None, + shape, + Some(target_surface), + text_layout_cache_rotation_only, + )? { + let rebound_text_content = + stored_text_content.paint_content_for_selrect(shape.selrect()); + let text_content = rebound_text_content.as_ref(); + let mut paragraph_builders = + text_content.paragraph_builder_group_from_text(None); + text::render( + Some(self), + None, + shape, + &mut paragraph_builders, + Some(target_surface), + None, + None, + None, + None, + )?; + } + } else { + fills::render(self, shape, &shape.fills, antialias, target_surface, None)?; + + // Clipped frames draw strokes in render_shape_exit over children. + let skip_strokes = matches!(shape.shape_type, Type::Frame(_)) && shape.clip_content; + if !skip_strokes { + // Pass strokes in natural order; stroke merging handles top-most ordering internally. + let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect(); + strokes::render( + self, + shape, + &visible_strokes, + Some(target_surface), + antialias, + outset, + )?; + } + } self.surfaces.apply_mut(target_surface as u32, |s| { s.canvas().restore(); @@ -1352,62 +1541,24 @@ impl RenderState { debug::render_debug_shape(self, Some(shape_selrect_bounds), None); } - if needs_save { - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas().restore(); - }); - } return Ok(()); } + // Only save canvas state if we have clipping or transforms + // For simple shapes without clipping, skip expensive save/restore + let needs_save = + clip_bounds.is_some() || offset.is_some() || !shape.transform.is_identity(); + + if needs_save { + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas().save(); + }); + } + // set clipping if let Some(clips) = clip_bounds.as_ref() { let scale = self.get_scale(); - for (mut bounds, corners, transform) in clips.iter() { - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas().concat(transform); - }); - - // Outset clip by ~0.5 to include edge pixels that - // aliased clip misclassifies as outside (causing artifacts). - let outset = 0.5 / scale; - bounds.outset((outset, outset)); - - // Hard clip edge (antialias = false) to avoid alpha seam when clipping - // semi-transparent content larger than the frame. - if let Some(corners) = corners { - let rrect = RRect::new_rect_radii(bounds, corners); - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas().clip_rrect(rrect, skia::ClipOp::Intersect, false); - }); - } else { - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas().clip_rect(bounds, skia::ClipOp::Intersect, false); - }); - } - - // This renders a red line around clipped - // shapes (frames). - if self.options.is_debug_visible() { - let mut paint = skia::Paint::default(); - paint.set_style(skia::PaintStyle::Stroke); - paint.set_color(skia::Color::from_argb(255, 255, 0, 0)); - paint.set_stroke_width(4.); - self.surfaces - .canvas(fills_surface_id) - .draw_rect(bounds, &paint); - } - - // Uncomment to debug the render_position_data - // if let Type::Text(text_content) = &shape.shape_type { - // text::render_position_data(self, fills_surface_id, &shape, text_content); - // } - - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas() - .concat(&transform.invert().unwrap_or(Matrix::default())); - }); - } + self.apply_clip_stack_to_surfaces(clips, surface_ids, scale, Some(fills_surface_id)); } // We don't want to change the value in the global state @@ -1499,126 +1650,38 @@ impl RenderState { s.canvas().concat(&matrix); }); - // Skip the paragraph-cloning `new_bounds` when shape size is unchanged. - let selrect = shape.selrect(); - let stored_bounds = stored_text_content.bounds(); - let bounds_match = (stored_bounds.width() - selrect.width()).abs() < 0.01 - && (stored_bounds.height() - selrect.height()).abs() < 0.01; - let rebound_text_content = if bounds_match { - None - } else { - Some(stored_text_content.new_bounds(selrect)) - }; - let text_content: &TextContent = - rebound_text_content.as_ref().unwrap_or(stored_text_content); - let count_inner_strokes = shape.count_visible_inner_strokes(); - // Erode the main text fill by 1px when there are inner strokes, to avoid a visible seam at the glyph edge. - let text_fill_inset = (count_inner_strokes > 0).then(|| 1.0 / self.get_scale()); - let text_stroke_blur_outset = - Stroke::max_bounds_width(shape.visible_strokes(), false); - let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); - let stroke_kinds: Vec = - shape.visible_strokes().rev().map(|s| s.kind).collect(); - let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape - .visible_strokes() - .rev() - .map(|stroke| { - text::stroke_paragraph_builder_group_from_text( - text_content, - stroke, - &shape.selrect(), - None, - ) - }) - .unzip(); - if skip_effects { - // Fast path: render fills and strokes only (skip shadows/blur). - text::render( + // Plain fill (no strokes / parent shadows): reuse cached layout + // paragraphs when valid. Skip builder rebuild + Skia layout. + let can_use_layout_cache = !shape.has_visible_strokes() + && parent_shadows.is_none() + && (skip_effects + || (shape.blur.is_none() + && !shape + .drop_shadows_visible() + .any(|s| s.is_perceptible_at_scale(self.get_scale())) + && shape.inner_shadow_paints().is_empty())); + if !(can_use_layout_cache + && text::try_paint_from_layout_cache( Some(self), None, &shape, - &mut paragraph_builders, Some(fills_surface_id), - None, - None, - text_fill_inset, - None, - )?; - - for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list - .iter_mut() - .zip(stroke_opacities.iter()) - .enumerate() - { - if stroke_kinds[i] == StrokeKind::Inner { - let mut fill_builders = - text_content.paragraph_builder_group_from_text(None); - text::render_inner_stroke( - Some(self), - None, - &shape, - stroke_paragraphs, - &mut fill_builders, - Some(strokes_surface_id), - None, - text_stroke_blur_outset, - *layer_opacity, - )?; - } else if stroke_kinds[i] == StrokeKind::Outer { - text::render_outer_stroke( - Some(self), - None, - &shape, - stroke_paragraphs, - Some(strokes_surface_id), - None, - text_stroke_blur_outset, - *layer_opacity, - )?; - } else { - text::render_with_bounds_outset( - Some(self), - None, - &shape, - stroke_paragraphs, - Some(strokes_surface_id), - None, - None, - text_stroke_blur_outset, - None, - *layer_opacity, - )?; - } - } - - if shape.has_visible_strokes() && text_content.has_non_ascii() { - let mut emoji_builders = text_content.paragraph_builder_group_opaque(); - let mut deco_builders = - text_content.paragraph_builder_group_from_text(None); - text::render_emoji_overlay( - self, - &shape, - &mut emoji_builders, - &mut deco_builders, - strokes_surface_id, - None, - ); - } - } else { - let mut drop_shadows = shape.drop_shadow_paints(); - - if let Some(inherited_shadows) = self.get_inherited_drop_shadows() { - drop_shadows.extend(inherited_shadows); - } - - let inner_shadows = shape.inner_shadow_paints(); - let blur_filter = shape.image_filter(1.); - let mut paragraphs_with_shadows = - text_content.paragraph_builder_group_from_text(Some(true)); - let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): ( - Vec<_>, - Vec<_>, - ) = shape + text_layout_cache_rotation_only, + )?) + { + let rebound_text_content = + stored_text_content.paint_content_for_selrect(shape.selrect()); + let text_content = rebound_text_content.as_ref(); + let count_inner_strokes = shape.count_visible_inner_strokes(); + // Erode the main text fill by 1px when there are inner strokes, to avoid a visible seam at the glyph edge. + let text_fill_inset = (count_inner_strokes > 0).then(|| 1.0 / self.get_scale()); + let text_stroke_blur_outset = + Stroke::max_bounds_width(shape.visible_strokes(), false); + let mut paragraph_builders = + text_content.paragraph_builder_group_from_text(None); + let stroke_kinds: Vec = + shape.visible_strokes().rev().map(|s| s.kind).collect(); + let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape .visible_strokes() .rev() .map(|stroke| { @@ -1626,58 +1689,12 @@ impl RenderState { text_content, stroke, &shape.selrect(), - Some(true), + None, ) }) .unzip(); - - if let Some(parent_shadows) = parent_shadows { - if !shape.has_visible_strokes() { - for shadow in parent_shadows { - text::render( - Some(self), - None, - &shape, - &mut paragraphs_with_shadows, - text_drop_shadows_surface_id.into(), - Some(&shadow), - blur_filter.as_ref(), - None, - None, - )?; - } - } else { - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - text_drop_shadows_surface_id.into(), - &parent_shadows, - &blur_filter, - &stroke_kinds, - text_content, - )?; - } - } else { - // 1. Text drop shadows - if !shape.has_visible_strokes() { - for shadow in &drop_shadows { - text::render( - Some(self), - None, - &shape, - &mut paragraphs_with_shadows, - text_drop_shadows_surface_id.into(), - Some(shadow), - blur_filter.as_ref(), - None, - None, - )?; - } - } - - // 2. Text fills + if skip_effects { + // Fast path: render fills and strokes only (skip shadows/blur). text::render( Some(self), None, @@ -1685,25 +1702,11 @@ impl RenderState { &mut paragraph_builders, Some(fills_surface_id), None, - blur_filter.as_ref(), + None, text_fill_inset, None, )?; - // 3. Stroke drop shadows - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - text_drop_shadows_surface_id.into(), - &drop_shadows, - &blur_filter, - &stroke_kinds, - text_content, - )?; - - // 4. Stroke fills for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list .iter_mut() .zip(stroke_opacities.iter()) @@ -1719,7 +1722,7 @@ impl RenderState { stroke_paragraphs, &mut fill_builders, Some(strokes_surface_id), - blur_filter.as_ref(), + None, text_stroke_blur_outset, *layer_opacity, )?; @@ -1730,7 +1733,7 @@ impl RenderState { &shape, stroke_paragraphs, Some(strokes_surface_id), - blur_filter.as_ref(), + None, text_stroke_blur_outset, *layer_opacity, )?; @@ -1742,7 +1745,7 @@ impl RenderState { stroke_paragraphs, Some(strokes_surface_id), None, - blur_filter.as_ref(), + None, text_stroke_blur_outset, None, *layer_opacity, @@ -1760,41 +1763,219 @@ impl RenderState { &mut emoji_builders, &mut deco_builders, strokes_surface_id, - blur_filter.as_ref(), + None, ); } + } else { + let shape_scale = self.get_scale(); + let mut drop_shadows = if skip_drop_shadows { + Vec::new() + } else { + shape + .drop_shadows_visible() + .filter(|s| s.is_perceptible_at_scale(shape_scale)) + .map(|shadow| { + let mut paint = skia_safe::Paint::default(); + paint.set_image_filter(shadow.get_drop_shadow_filter()); + paint + }) + .collect() + }; - // 5. Stroke inner shadows - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - Some(innershadows_surface_id), - &inner_shadows, - &blur_filter, - &stroke_kinds, - text_content, - )?; + if !skip_drop_shadows { + if let Some(inherited_shadows) = self.get_inherited_drop_shadows() { + drop_shadows.extend(inherited_shadows); + } + } - // 6. Fill Inner shadows - if !shape.has_visible_strokes() { - for shadow in &inner_shadows { - text::render( - Some(self), - None, + let inner_shadows = shape.inner_shadow_paints(); + let blur_filter = shape.image_filter(1.); + let mut paragraphs_with_shadows = + text_content.paragraph_builder_group_from_text(Some(true)); + let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): ( + Vec<_>, + Vec<_>, + ) = shape + .visible_strokes() + .rev() + .map(|stroke| { + text::stroke_paragraph_builder_group_from_text( + text_content, + stroke, + &shape.selrect(), + Some(true), + ) + }) + .unzip(); + + if let Some(parent_shadows) = parent_shadows { + if !skip_drop_shadows { + if !shape.has_visible_strokes() { + for shadow in parent_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + text_drop_shadows_surface_id.into(), + Some(&shadow), + blur_filter.as_ref(), + None, + None, + )?; + } + } else { + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + text_drop_shadows_surface_id.into(), + &parent_shadows, + &blur_filter, + &stroke_kinds, + text_content, + )?; + } + } + } else { + // 1. Text drop shadows + if !shape.has_visible_strokes() { + for shadow in &drop_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + text_drop_shadows_surface_id.into(), + Some(shadow), + blur_filter.as_ref(), + None, + None, + )?; + } + } + + // 2. Text fills + text::render( + Some(self), + None, + &shape, + &mut paragraph_builders, + Some(fills_surface_id), + None, + blur_filter.as_ref(), + text_fill_inset, + None, + )?; + + // 3. Stroke drop shadows + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + text_drop_shadows_surface_id.into(), + &drop_shadows, + &blur_filter, + &stroke_kinds, + text_content, + )?; + + // 4. Stroke fills + for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list + .iter_mut() + .zip(stroke_opacities.iter()) + .enumerate() + { + if stroke_kinds[i] == StrokeKind::Inner { + let mut fill_builders = + text_content.paragraph_builder_group_from_text(None); + text::render_inner_stroke( + Some(self), + None, + &shape, + stroke_paragraphs, + &mut fill_builders, + Some(strokes_surface_id), + blur_filter.as_ref(), + text_stroke_blur_outset, + *layer_opacity, + )?; + } else if stroke_kinds[i] == StrokeKind::Outer { + text::render_outer_stroke( + Some(self), + None, + &shape, + stroke_paragraphs, + Some(strokes_surface_id), + blur_filter.as_ref(), + text_stroke_blur_outset, + *layer_opacity, + )?; + } else { + text::render_with_bounds_outset( + Some(self), + None, + &shape, + stroke_paragraphs, + Some(strokes_surface_id), + None, + blur_filter.as_ref(), + text_stroke_blur_outset, + None, + *layer_opacity, + )?; + } + } + + if shape.has_visible_strokes() && text_content.has_non_ascii() { + let mut emoji_builders = + text_content.paragraph_builder_group_opaque(); + let mut deco_builders = + text_content.paragraph_builder_group_from_text(None); + text::render_emoji_overlay( + self, &shape, - &mut paragraphs_with_shadows, - Some(innershadows_surface_id), - Some(shadow), + &mut emoji_builders, + &mut deco_builders, + strokes_surface_id, blur_filter.as_ref(), - None, - None, - )?; + ); + } + + // 5. Stroke inner shadows + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + Some(innershadows_surface_id), + &inner_shadows, + &blur_filter, + &stroke_kinds, + text_content, + )?; + + // 6. Fill Inner shadows + if !shape.has_visible_strokes() { + for shadow in &inner_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + Some(innershadows_surface_id), + Some(shadow), + blur_filter.as_ref(), + None, + None, + )?; + } } } } - } + } // end layout-cache miss fallback } _ => { self.surfaces.apply_mut(surface_ids, |s| { @@ -2190,6 +2371,8 @@ impl RenderState { // reorder by distance to the center. self.current_tile = None; + self.drop_shadow_filter_cache.clear(); + self.viewport_presented = false; } pub fn start_render_loop( @@ -2278,7 +2461,7 @@ impl RenderState { performance::begin_measure!("tile_cache"); let only_visible = self.options.is_interactive_transform(); self.pending_tiles - .update(&self.tile_viewbox, &self.surfaces, only_visible); + .update(&self.tile_viewbox, &self.surfaces, scale, only_visible); performance::end_measure!("tile_cache"); performance::end_timed_log!("tile_cache_update", _tile_start); @@ -2357,18 +2540,34 @@ impl RenderState { allow_stop: bool, ) -> Result { performance::begin_measure!("continue_render_loop"); + let timestamp = self.render_budget_start(timestamp); let frame_type = self.render_shape_tree_partial(base_object, tree, timestamp, allow_stop)?; // `draw_atlas` needs a snapshot of the tile atlas. Partial frames are not - // presented (only flushed), so defer composition to the final frame and - // avoid re-snapshotting up to 4096² on every rAF during async tile work. - if !self.options.is_interactive_transform() && matches!(frame_type, FrameType::Full) { - self.surfaces.draw_tile_atlas_to_backbuffer( - &self.viewbox, - &self.tile_viewbox, - self.background_color, - ); + // presented (only flushed), so defer composition until the viewport is + // ready and avoid re-snapshotting up to 4096² on every rAF during async + // tile work. + let should_compose = !self.options.is_interactive_transform() + && matches!(frame_type, FrameType::Full | FrameType::ViewportReady) + && !self.viewport_presented; + + if should_compose { + // Fast mode skips the tile atlas; use the same doc-atlas + scale + // overlays as render_from_cache instead of composing empty slots. + if self.options.is_fast_mode() { + self.surfaces.draw_combined_atlas_to_backbuffer( + &self.viewbox, + &self.tile_viewbox, + self.background_color, + ); + } else { + self.surfaces.draw_tile_atlas_to_backbuffer( + &self.viewbox, + &self.tile_viewbox, + self.background_color, + ); + } } match frame_type { @@ -2376,21 +2575,35 @@ impl RenderState { panic!("FrameType::None"); } FrameType::Partial => { - // Partial frame: just flush GPU work. The display shows the last - // fully submitted frame; no need to copy or draw UI overlays here. - self.flush(); + // Final soft drain for this yield (mid-walk also drains; see + // `drain_partial_gpu_soft`). Full still submits via present_frame. + Self::drain_partial_gpu_soft(); + } + FrameType::ViewportReady => { + // Visible tiles are done: present now so the user sees the + // viewport without waiting for interest-ring pre-render. + // Defer crop-cache rebuild to Full — it is expensive on large + // HiDPI viewports and is not needed until the next drag. + self.present_frame(tree); + self.viewport_presented = true; + wapi::notify_tiles_render_complete!(); + Self::drain_partial_gpu_soft(); } FrameType::Full => { - // A full-quality frame is now complete. Rebuild the per-shape crop - // cache from the clean Backbuffer (no UI overlay yet) so that - // interactive drag backgrounds don't include the grid overlay. - if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { + if !self.viewport_presented { + // A full-quality frame is now complete (no early viewport + // present). Rebuild crop cache and present. + if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { + self.rebuild_backbuffer_crop_cache(tree); + } + self.present_frame(tree); + wapi::notify_tiles_render_complete!(); + } else if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { + // Interest fill finished after ViewportReady. Backbuffer + // still holds the viewport compose; rebuild crop cache + // off the sharp-snap frame. self.rebuild_backbuffer_crop_cache(tree); } - // present_frame: copy clean Backbuffer → Target, draw UI/debug - // overlays on Target only, then flush. Backbuffer stays overlay-free. - self.present_frame(tree); - wapi::notify_tiles_render_complete!(); performance::end_measure!("render"); } } @@ -2404,6 +2617,7 @@ impl RenderState { tree: ShapesPoolRef, timestamp: i32, ) -> Result { + let timestamp = self.render_budget_start(timestamp); self.render_shape_tree_partial(base_object, tree, timestamp, false)?; // Same composition as `continue_render_loop` for full frames: snapshot only the @@ -2538,6 +2752,24 @@ impl RenderState { Ok((data.as_bytes().to_vec(), width, height)) } + /// Anchor the progressive render budget to wall-clock now when the + /// caller-provided timestamp is unusable: + /// - Frontend sometimes passes `0` (finalize-view / debounced zoom-end). + /// - rAF may hand a timestamp that is already older than the budget when + /// the handler runs late. Using that stamp made `should_stop_rendering` + /// yield after a few nodes with ~0ms of real work. + #[inline] + fn render_budget_start(&self, timestamp: i32) -> i32 { + let now = performance::get_time(); + if timestamp <= 0 { + return now; + } + if now - timestamp > self.options.max_blocking_time_ms { + return now; + } + timestamp + } + #[inline] pub fn should_stop_rendering(&self, iteration: i32, timestamp: i32) -> bool { if iteration % self.options.node_batch_threshold != 0 { @@ -2562,6 +2794,28 @@ impl RenderState { true } + /// Soft-drain GPU command buffers during progressive tile walks. + /// Release packs far more cheap Current draws (e.g. fills_none paths) into + /// one Partial than debug; flushing only at Partial end then stalls. Call + /// periodically so each flush stays small. Full present still submits. + #[inline] + fn drain_partial_gpu_soft() { + crate::get_gpu_state().context.flush(None); + } + + /// Skip all drop/inner shadows in fast mode, or when even a large design-space + /// shadow would be subpixel. Otherwise filter per shadow via + /// [`Shadow::is_perceptible_at_scale_for`] (stricter for recursive shapes). + #[inline] + pub(crate) fn should_skip_drop_shadows(&self) -> bool { + if self.options.is_fast_mode() { + return true; + } + let scale = self.get_scale(); + scale * crate::shapes::DROP_SHADOW_LARGE_DESIGN_PX + < crate::shapes::DROP_SHADOW_MIN_DEVICE_PX + } + #[inline] fn clip_target_surface_to_stack( &mut self, @@ -2790,6 +3044,7 @@ impl RenderState { None, None, target_surface, + false, )?; } @@ -2869,6 +3124,88 @@ impl RenderState { )) } + /// Renders descendant silhouettes into the current drop-shadow layer. + #[allow(clippy::too_many_arguments)] + fn render_drop_shadow_child_silhouettes( + &mut self, + element: &Shape, + tree: ShapesPoolRef, + shadow: &Shadow, + scale: f32, + inherited_layer_blur: Option, + node_render_state: &NodeRenderState, + target_surface: SurfaceId, + ) -> Result<()> { + if matches!(element.shape_type, Type::Bool(_)) { + return Ok(()); + } + + let shadow_children = if element.is_recursive() { + get_simplified_children(tree, element) + } else { + Vec::new() + }; + + for shadow_shape_id in shadow_children.iter() { + let Some(shadow_shape) = tree.get(shadow_shape_id) else { + continue; + }; + if shadow_shape.hidden { + continue; + } + + let nested_clip_bounds = + node_render_state.get_nested_shadow_clip_bounds(element, shadow); + + if !matches!(shadow_shape.shape_type, Type::Text(_)) { + self.render_drop_black_shadow( + shadow_shape, + &shadow_shape.extrect(tree, scale), + shadow, + nested_clip_bounds, + scale, + inherited_layer_blur, + target_surface, + )?; + } else { + let paint = skia::Paint::default(); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + self.surfaces + .canvas(SurfaceId::DropShadows) + .save_layer(&layer_rec); + + let mut transformed_shadow: Cow = Cow::Borrowed(shadow); + transformed_shadow.to_mut().color = skia::Color::BLACK; + transformed_shadow.to_mut().blur = transformed_shadow.blur; + transformed_shadow.to_mut().spread = transformed_shadow.spread; + + let mut new_shadow_paint = skia::Paint::default(); + new_shadow_paint.set_image_filter(transformed_shadow.get_drop_shadow_filter()); + new_shadow_paint.set_blend_mode(skia::BlendMode::SrcOver); + + self.with_nested_blurs_suppressed(|state| { + state.render_shape( + shadow_shape, + nested_clip_bounds, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + true, + None, + Some(vec![new_shadow_paint.clone()]), + None, + target_surface, + false, + ) + })?; + self.surfaces.canvas(SurfaceId::DropShadows).restore(); + } + } + + Ok(()) + } + /// Renders a drop shadow effect for the given shape. /// /// Creates a black shadow by converting the original shadow color to black, @@ -2964,6 +3301,7 @@ impl RenderState { None, Some(shadow.spread), target_surface, + false, ) })?; @@ -3007,6 +3345,7 @@ impl RenderState { None, Some(shadow.spread), target_surface, + false, ) })?; @@ -3014,10 +3353,30 @@ impl RenderState { return Ok(()); } - // Adaptive downscale for large blur values (lossless GPU optimization). - // Bounds above were computed from the original sigma so filter surface coverage is correct. - // Maximum downscale is 1/BLUR_DOWNSCALE_THRESHOLD (i.e. 8x): beyond that the - // filter surface becomes too small and quality degrades noticeably. + // High zoom with blur: use render_into_filter_surface to ensure blur has enough space + // Apply spread geometrically to avoid dilate filter rounding issues + let layer_blur_value = combined_blur.map(|b| b.value).unwrap_or(0.0); + let cache_key = clip_bounds.is_none().then(|| { + shadows::DropShadowFilterCacheKey::for_shape( + shape.id, + shadow, + scale, + &shape.transform, + layer_blur_value, + ) + }); + + if let Some(ref key) = cache_key { + if let Some(cached) = self.drop_shadow_filter_cache.lookup(key) { + shadows::blit_cached_drop_shadow_filter( + &mut self.surfaces, + cached, + blur_filter.clone(), + ); + return Ok(()); + } + } + let blur_downscale_threshold: f32 = self.options.blur_downscale_threshold; let min_blur_downscale: f32 = 1.0 / blur_downscale_threshold; let blur_downscale = if shadow.blur > blur_downscale_threshold { @@ -3050,6 +3409,7 @@ impl RenderState { None, Some(shadow.spread), target_surface, + false, ) })?; @@ -3059,37 +3419,19 @@ impl RenderState { )?; if let Some((mut surface, filter_scale)) = filter_result { - let drop_canvas = self.surfaces.canvas(SurfaceId::DropShadows); - drop_canvas.save(); - //drop_canvas.scale((scale, scale)); - //drop_canvas.translate(translation); - let mut drop_paint = skia::Paint::default(); - drop_paint.set_image_filter(blur_filter.clone()); - - // If we scaled down in the filter surface, we need to scale back up - if filter_scale < 1.0 { - drop_canvas.save(); - drop_canvas.scale((1.0 / filter_scale, 1.0 / filter_scale)); - drop_canvas.translate((bounds.left * filter_scale, bounds.top * filter_scale)); - surface.draw( - drop_canvas, - (0.0, 0.0), - get_resources().sampling_options, - Some(&drop_paint), - ); - drop_canvas.restore(); - } else { - drop_canvas.save(); - drop_canvas.translate((bounds.left, bounds.top)); - surface.draw( - drop_canvas, - (0.0, 0.0), - get_resources().sampling_options, - Some(&drop_paint), - ); - drop_canvas.restore(); + let cached = shadows::CachedDropShadowFilter::new( + bounds, + filter_scale, + surface.image_snapshot(), + ); + shadows::blit_cached_drop_shadow_filter( + &mut self.surfaces, + &cached, + blur_filter.clone(), + ); + if let Some(key) = cache_key { + self.drop_shadow_filter_cache.store(key, cached); } - drop_canvas.restore(); } Ok(()) @@ -3097,6 +3439,7 @@ impl RenderState { /// Renders element drop shadows to DropShadows surface and composites to Current. /// Used for both normal shadow rendering and pre-layer rendering (frame_clip_layer_blur). + /// Returns `true` when at least one visible drop shadow was composited. #[allow(clippy::too_many_arguments)] fn render_element_drop_shadows_and_composite( &mut self, @@ -3107,92 +3450,68 @@ impl RenderState { scale: f32, node_render_state: &NodeRenderState, target_surface: SurfaceId, - ) -> Result<()> { + ) -> Result { + // Avoid a blank DropShadows→Current blit + clear when nothing will paint + // (no shadows, fast/overview skip, or all footprints subpixel). Callers + // must still touch DropShadows once per tile when this returns false + // (see `drop_shadows_ops_warmed`). + if self.should_skip_drop_shadows() + || !element + .drop_shadows_visible() + .any(|s| s.is_perceptible_at_scale_for(scale, element.is_recursive())) + { + return Ok(false); + } + let element_extrect = extrect.get_or_insert_with(|| element.extrect(tree, scale)); let inherited_layer_blur = match element.shape_type { Type::Frame(_) | Type::Group(_) => element.blur, _ => None, }; + let recursive = element.is_recursive(); + let use_direct_container_shadow = element.uses_direct_container_drop_shadow(tree, scale); + let mut rendered_any = false; for shadow in element.drop_shadows_visible() { + if !shadow.is_perceptible_at_scale_for(scale, recursive) { + continue; + } + rendered_any = true; let paint = skia::Paint::default(); let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); self.surfaces .canvas(SurfaceId::DropShadows) .save_layer(&layer_rec); - self.render_drop_black_shadow( - element, - element_extrect, - shadow, - clip_bounds.clone(), - scale, - None, - target_surface, - )?; - - if !matches!(element.shape_type, Type::Bool(_)) { - let shadow_children = if element.is_recursive() { - get_simplified_children(tree, element) - } else { - Vec::new() - }; - - for shadow_shape_id in shadow_children.iter() { - let Some(shadow_shape) = tree.get(shadow_shape_id) else { - continue; - }; - if shadow_shape.hidden { - continue; - } - - let nested_clip_bounds = - node_render_state.get_nested_shadow_clip_bounds(element, shadow); - - if !matches!(shadow_shape.shape_type, Type::Text(_)) { - self.render_drop_black_shadow( - shadow_shape, - &shadow_shape.extrect(tree, scale), - shadow, - nested_clip_bounds, - scale, - inherited_layer_blur, - target_surface, - )?; - } else { - let paint = skia::Paint::default(); - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); - self.surfaces - .canvas(SurfaceId::DropShadows) - .save_layer(&layer_rec); - - let mut transformed_shadow: Cow = Cow::Borrowed(shadow); - transformed_shadow.to_mut().color = skia::Color::BLACK; - transformed_shadow.to_mut().blur = transformed_shadow.blur; - transformed_shadow.to_mut().spread = transformed_shadow.spread; - - let mut new_shadow_paint = skia::Paint::default(); - new_shadow_paint - .set_image_filter(transformed_shadow.get_drop_shadow_filter()); - new_shadow_paint.set_blend_mode(skia::BlendMode::SrcOver); - - self.with_nested_blurs_suppressed(|state| { - state.render_shape( - shadow_shape, - nested_clip_bounds, - SurfaceId::DropShadows, - SurfaceId::DropShadows, - SurfaceId::DropShadows, - SurfaceId::DropShadows, - true, - None, - Some(vec![new_shadow_paint.clone()]), - None, - target_surface, - ) - })?; - self.surfaces.canvas(SurfaceId::DropShadows).restore(); - } + // Fast path: frame geometry only (no child silhouettes). + if use_direct_container_shadow { + shadows::render_direct_frame_drop_shadow( + self, + element, + element_extrect, + shadow, + scale, + )?; + } else { + self.render_drop_black_shadow( + element, + element_extrect, + shadow, + clip_bounds.clone(), + scale, + None, + target_surface, + )?; + if !element.container_fill_covers_shadow_descendants(tree, scale) { + self.render_drop_shadow_child_silhouettes( + element, + tree, + shadow, + scale, + inherited_layer_blur, + node_render_state, + target_surface, + )?; } } @@ -3206,6 +3525,10 @@ impl RenderState { self.surfaces.canvas(SurfaceId::DropShadows).restore(); } + if !rendered_any { + return Ok(false); + } + if let Some(clips) = clip_bounds.as_ref() { let antialias = !self.options.is_fast_mode() && element.should_use_antialias(scale, self.options.antialias_threshold); @@ -3222,7 +3545,7 @@ impl RenderState { self.surfaces .canvas(SurfaceId::DropShadows) .clear(skia::Color::TRANSPARENT); - Ok(()) + Ok(true) } pub fn render_shape_tree_partial_uncached( @@ -3457,13 +3780,15 @@ impl RenderState { // the layer blur (which would make it more diffused than without clipping) let shadow_before_layer = !node_render_state.is_root() && self.focus_mode.is_active() - && !self.options.is_fast_mode() + && !self.should_skip_drop_shadows() && !matches!(element.shape_type, Type::Text(_)) && Self::frame_clip_layer_blur(element).is_some() - && element.drop_shadows_visible().next().is_some(); + && element + .drop_shadows_visible() + .any(|s| s.is_perceptible_at_scale_for(scale, element.is_recursive())); - if shadow_before_layer { - self.render_element_drop_shadows_and_composite( + if shadow_before_layer + && self.render_element_drop_shadows_and_composite( element, tree, &mut extrect, @@ -3471,7 +3796,9 @@ impl RenderState { scale, &node_render_state, target_surface, - )?; + )? + { + self.drop_shadows_ops_warmed = true; } // Render background blur BEFORE save_layer so it modifies @@ -3484,8 +3811,8 @@ impl RenderState { } if !node_render_state.is_root() && self.focus_mode.is_active() { - // Skip expensive drop shadow rendering in fast mode (during pan/zoom). - let skip_shadows = self.options.is_fast_mode(); + // Skip expensive drop shadows in fast mode and at overview zooms. + let skip_shadows = self.should_skip_drop_shadows(); // Skip shadow block when already rendered before the layer (frame_clip_layer_blur) let shadows_already_rendered = Self::frame_clip_layer_blur(element).is_some(); @@ -3494,8 +3821,7 @@ impl RenderState { if !skip_shadows && !shadows_already_rendered && !matches!(element.shape_type, Type::Text(_)) - { - self.render_element_drop_shadows_and_composite( + && self.render_element_drop_shadows_and_composite( element, tree, &mut extrect, @@ -3503,11 +3829,26 @@ impl RenderState { scale, &node_render_state, target_surface, - )?; - } else { - // This is necessary or the later flush_and_submit will be very slow + )? + { + // Real shadow composite already clears DropShadows. + self.drop_shadows_ops_warmed = true; + } + + if !self.drop_shadows_ops_warmed { + // Touch DropShadows→Current once per tile when no shape has + // composited real shadows yet. Omitting this entirely made + // flush_and_submit very slow (ops-task ordering); repeating + // it per shape was waste. + self.surfaces.draw_into( + SurfaceId::DropShadows, + target_surface, + Some(&skia::Paint::default()), + ); self.surfaces - .draw_into(SurfaceId::DropShadows, target_surface, None); + .canvas(SurfaceId::DropShadows) + .clear(skia::Color::TRANSPARENT); + self.drop_shadows_ops_warmed = true; } // For frames without clip_content, inner strokes must render after children in @@ -3526,6 +3867,9 @@ impl RenderState { Cow::Borrowed(element) }; + let text_layout_cache_rotation_only = self.options.is_interactive_transform() + && text_layout_cache_rotation_only(tree, element); + self.render_shape( &element_for_inline, clip_bounds.clone(), @@ -3538,6 +3882,7 @@ impl RenderState { None, None, target_surface, + text_layout_cache_rotation_only, )?; self.surfaces @@ -3614,6 +3959,12 @@ impl RenderState { if allow_stop && self.should_stop_rendering(iteration, timestamp) { return Ok((is_empty, true)); } + // Keep GPU ops buffers bounded when many shapes paint cheaply to + // Current (release packs far more per Partial than debug). + let drain_every = self.options.partial_gpu_drain_every_n; + if allow_stop && drain_every > 0 && iteration > 0 && iteration % drain_every == 0 { + Self::drain_partial_gpu_soft(); + } iteration += 1; } @@ -3646,7 +3997,10 @@ impl RenderState { // is not cached because everything will be handled from draw_atlas. // Viewer masked passes (include_filter) must not reuse cached tiles from // a previous pass; otherwise pass-1 pixels can leak into pass 2. - if self.viewer_masked_pass() || !self.surfaces.has_cached_tile_surface(current_tile) + if self.viewer_masked_pass() + || !self + .surfaces + .has_cached_tile_surface(current_tile, self.get_scale()) { performance::begin_measure!("render_shape_tree::uncached"); let (is_empty, early_return) = self @@ -3691,7 +4045,9 @@ impl RenderState { } } } else if self.tiles.is_empty_at(current_tile) { - self.surfaces.remove_cached_tile_surface(current_tile); + // Keep other-scale entries for mid-zoom overlays. + self.surfaces + .remove_cached_tile_surface_at(current_tile, self.get_scale()); } } @@ -3709,8 +4065,10 @@ impl RenderState { // empty tile. self.current_tile_had_shapes = false; self.tile_atlas_flushed = false; + self.drop_shadows_ops_warmed = false; let viewer_masked_pass = self.viewer_masked_pass(); + let current_scale = self.get_scale(); let Some(ids) = self.tiles.get_shapes_at(next_tile) else { // If the tile is empty we do not need to render it. @@ -3718,7 +4076,11 @@ impl RenderState { }; // Never skip based on cached surfaces during viewer masked passes. - if !viewer_masked_pass && self.surfaces.has_cached_tile_surface(next_tile) { + if !viewer_masked_pass + && self + .surfaces + .has_cached_tile_surface(next_tile, current_scale) + { // If the tile is cached, then we do not need to // render it. continue; @@ -3765,8 +4127,18 @@ impl RenderState { flattened: false, })); } else { - // If there are no more pending tiles, stop. - should_stop = true; + // Visible tiles finished. Promote deferred interest-ring work + // so pan/zoom pre-render still happens, but yield first when + // allowed so continue_render_loop can present the viewport. + if self.pending_tiles.promote_deferred_interest() { + if allow_stop { + should_stop = true; + } + // Sync path (allow_stop=false): keep looping on interest + // tiles in the same call without an early present. + } else { + should_stop = true; + } } } @@ -3784,6 +4156,12 @@ impl RenderState { self.cached_viewbox = self.viewbox; } + // Visible done with interest still queued and we yielded: present + // viewport now, keep Partial-like rAFs for the ring. + if allow_stop && !self.pending_tiles.list.is_empty() { + return Ok(FrameType::ViewportReady); + } + Ok(FrameType::Full) } @@ -3883,22 +4261,8 @@ impl RenderState { result } - /* - * Incremental version of update_shape_tiles for pan/zoom operations. - * Updates the tile index and returns ONLY tiles that need cache invalidation. - * - * During pan operations, shapes don't move in world coordinates. The interest - * area (viewport) moves, which changes which tiles we track in the index, but - * tiles that were already cached don't need re-rendering just because the - * viewport moved. - * - * This function: - * 1. Updates the tile index (adds/removes shapes from tiles based on interest area) - * 2. Returns empty vec for cache invalidation (pan doesn't change tile content) - * - * Tile cache invalidation only happens when shapes actually move or change, - * which is handled by rebuild_touched_tiles, not during pan/zoom. - */ + /// Diffs the shape's tile set, leaving cached tiles alone. For callers where the + /// index moves but painted content does not: pan/zoom. pub fn update_shape_tiles_incremental( &mut self, shape: &Shape, @@ -3953,8 +4317,9 @@ impl RenderState { } /// Rebuild the tile index (shape→tile mapping) for all top-level shapes. - /// This does NOT invalidate the tile texture cache — cached tile images - /// survive so that fast-mode renders during pan still show shadows/blur. + /// This does NOT invalidate the tile texture cache — existing HQ tiles + /// survive across pan so `render_from_cache` keeps showing shadows/blur + /// until the post-gesture full render replaces them. pub fn rebuild_tile_index(&mut self, tree: ShapesPoolRef) { let zoom_changed = self.zoom_changed(); performance::begin_measure!("rebuild_tile_index"); @@ -4038,9 +4403,8 @@ impl RenderState { pub fn rebuild_touched_tiles(&mut self, tree: ShapesPoolRef) { performance::begin_measure!("rebuild_touched_tiles"); - let mut all_tiles = HashSet::::new(); - let ids = std::mem::take(&mut self.touched_ids); + let prev_extrects = std::mem::take(&mut self.touched_prev_extrects); // Pan release sets `preserve_target` in `set_view_end`; don't reset it // here when no shapes changed, or the next render clears the canvas. if !ids.is_empty() { @@ -4050,62 +4414,104 @@ impl RenderState { for shape_id in ids.iter() { if let Some(shape) = tree.get(shape_id) { if shape_id != &Uuid::nil() { - all_tiles.extend(self.update_shape_tiles(shape, tree)); + self.invalidate_shape_and_update_tiles( + shape, + tree, + prev_extrects.get(shape_id).copied(), + ); } } } - // Update the changed tiles - for tile in all_tiles { - self.remove_cached_tile(tile); - } + self.index_dependent_ancestors(&ids, tree); performance::end_measure!("rebuild_touched_tiles"); } - /// Invalidates extended rectangles and updates tiles for a set of shapes - /// - /// This function takes a set of shape IDs and for each one: - /// 1. Invalidates the extrect cache - /// 2. Updates the tiles to ensure proper rendering - /// - /// This is useful when you have a pre-computed set of shape IDs that need to be refreshed, - /// regardless of their relationship to other shapes (e.g., ancestors, descendants, or any other collection). - pub fn update_tiles_shapes( - &mut self, - shape_ids: &[Uuid], - tree: ShapesPoolMutRef<'_>, - ) -> Result<()> { + /// Re-indexes a set of shapes and evicts the cached tiles they dirty. Extrect caches + /// are not dropped here: `State::touch_shape` and `rebuild_modifier_tiles` invalidate + /// them at the source. + pub fn update_tiles_shapes(&mut self, shape_ids: &[Uuid], tree: ShapesPoolRef) -> Result<()> { performance::begin_measure!("invalidate_and_update_tiles"); - let mut all_tiles = HashSet::::new(); for shape_id in shape_ids { if let Some(shape) = tree.get(shape_id) { - all_tiles.extend(self.update_shape_tiles(shape, tree)); + self.invalidate_shape_and_update_tiles(shape, tree, None); } } - for tile in all_tiles { - self.remove_cached_tile(tile); - } performance::end_measure!("invalidate_and_update_tiles"); Ok(()) } - /// Rebuilds tiles for shapes with modifiers and processes their ancestors - /// - /// This function applies transformation modifiers to shapes and updates their tiles. - /// Additionally, it processes all ancestors of modified shapes to ensure their - /// extended rectangles are properly recalculated and their tiles are updated. - /// This is crucial for frames and groups that contain transformed children. + /// old∪new∪indexed document coverage used to evict cached tiles after edits. + fn dirty_doc_rect_for_shape( + &mut self, + shape: &Shape, + tree: ShapesPoolRef, + prev_extrect: Option, + ) -> skia::Rect { + let scale = self.get_scale(); + let new_extrect = self.get_cached_extrect(shape, tree, 1.0); + let prev_extrect = prev_extrect.or_else(|| { + tree.get_modifier(&shape.id) + .and_then(|_| tree.get_raw(&shape.id).map(|raw| raw.extrect(tree, 1.0))) + }); + let indexed = self + .tiles + .get_tiles_of(shape.id) + .into_iter() + .flatten() + .fold(skia::Rect::new_empty(), |acc, tile| { + tiles::join_nonempty(acc, tiles::get_tile_rect(*tile, scale)) + }); + tiles::union_edit_dirty_rect(prev_extrect, new_extrect, indexed) + } + + fn invalidate_shape_and_update_tiles( + &mut self, + shape: &Shape, + tree: ShapesPoolRef, + prev_extrect: Option, + ) { + let dirty = self.dirty_doc_rect_for_shape(shape, tree, prev_extrect); + let _ = self.update_shape_tiles(shape, tree); + self.surfaces.invalidate_cached_tiles_intersecting(dirty); + } + + fn index_dependent_ancestors(&mut self, ids: &HashSet, tree: ShapesPoolRef) { + if ids.is_empty() { + return; + } + + let mut seen = HashSet::::new(); + + for id in ids.iter() { + for ancestor_id in tree.dependent_ancestor_ids(id) { + if ids.contains(&ancestor_id) || !seen.insert(ancestor_id) { + break; + } + let Some(shape) = tree.get(&ancestor_id) else { + continue; + }; + // A hidden ancestor paints nothing, but its parent may + if shape.hidden() { + continue; + } + let _ = self.update_shape_tiles_incremental(shape, tree); + } + } + } + pub fn rebuild_modifier_tiles( &mut self, tree: ShapesPoolMutRef<'_>, ids: &[Uuid], ) -> Result<()> { - // During interactive transform, skip ancestor invalidation: walking up to the - // parent frame evicts every tile the frame covers, including dense tiles with - // many siblings. Ancestor extrect caches are already invalidated by - // `ShapesPool::set_modifiers`; the tile index is reconciled post-gesture by - // the committing code path (rebuild_touched_tiles). + // `set_modifiers` runs per pointer move, this runs once per rAF, so the ancestor + // caches are dropped here. Must precede any read of their tile coverage below. + for id in ids { + tree.invalidate_ancestors_extrect(id); + } + if self.options.is_interactive_transform() { self.update_tiles_shapes(ids, tree)?; } else { @@ -4128,12 +4534,21 @@ impl RenderState { } pub fn mark_touched(&mut self, uuid: Uuid) { - self.touched_ids.insert(uuid); + self.mark_touched_with_prev(uuid, None); + } + + pub fn mark_touched_with_prev(&mut self, uuid: Uuid, prev_extrect: Option) { + if self.touched_ids.insert(uuid) { + if let Some(rect) = prev_extrect.filter(|r| !r.is_empty()) { + self.touched_prev_extrects.insert(uuid, rect); + } + } } #[allow(dead_code)] pub fn clean_touched(&mut self) { self.touched_ids.clear(); + self.touched_prev_extrects.clear(); } pub fn get_cached_extrect(&mut self, shape: &Shape, tree: ShapesPoolRef, scale: f32) -> Rect { diff --git a/render-wasm/src/render/fills.rs b/render-wasm/src/render/fills.rs index d7010cc2e5..bebd20f7c6 100644 --- a/render-wasm/src/render/fills.rs +++ b/render-wasm/src/render/fills.rs @@ -56,6 +56,15 @@ fn clip_to_shape( } } +/// Axis-aligned rect/frame with no corner radii: `dest` fills `selrect`, so a +/// clip to the container is a no-op before `draw_image_rect`. +fn is_axis_aligned_image_rect(shape: &Shape) -> bool { + matches!( + &shape.shape_type, + Type::Rect(Rect { corners: None }) | Type::Frame(Frame { corners: None, .. }) + ) +} + fn draw_image_fill( render_state: &mut RenderState, shape: &Shape, @@ -85,29 +94,53 @@ fn draw_image_fill( let src_rect = get_source_rect(size, container, image_fill); let dest_rect = container; + let sampling = get_resources().sampling_options; - let mut image_paint = skia::Paint::default(); - image_paint.set_anti_alias(antialias); + // `save_layer` is only required when a shape-level image filter (blur) must + // run over the clipped image. Otherwise a plain save/clip (or no clip for + // axis-aligned rects) avoids an offscreen buffer per fill — the hot path + // for photo-heavy boards during tile walks. if let Some(filter) = shape.image_filter(1.) { - image_paint.set_image_filter(filter.clone()); + let mut layer_paint = skia::Paint::default(); + layer_paint.set_anti_alias(antialias); + layer_paint.set_image_filter(filter); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&layer_paint); + canvas.save_layer(&layer_rec); + clip_to_shape(canvas, shape, container, antialias); + canvas.draw_image_rect_with_sampling_options( + image, + Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)), + dest_rect, + sampling, + paint, + ); + canvas.restore(); + return; } - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&image_paint); - // Save the current canvas state - canvas.save_layer(&layer_rec); + let mut draw_paint = paint.clone(); + draw_paint.set_anti_alias(antialias); + if is_axis_aligned_image_rect(shape) { + canvas.draw_image_rect_with_sampling_options( + image, + Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)), + dest_rect, + sampling, + &draw_paint, + ); + return; + } + + canvas.save(); clip_to_shape(canvas, shape, container, antialias); - - // Draw the image with the calculated destination rectangle canvas.draw_image_rect_with_sampling_options( image, Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)), dest_rect, - get_resources().sampling_options, - paint, + sampling, + &draw_paint, ); - - // Restore the canvas to remove the clipping canvas.restore(); } diff --git a/render-wasm/src/render/fonts.rs b/render-wasm/src/render/fonts.rs index 17bdb84290..5df5403bc4 100644 --- a/render-wasm/src/render/fonts.rs +++ b/render-wasm/src/render/fonts.rs @@ -1,5 +1,5 @@ use skia_safe::{self as skia, textlayout, Font, FontMgr}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use crate::error::{Error, Result}; use crate::shapes::{FontFamily, FontStyle}; @@ -26,6 +26,9 @@ pub struct FontStore { debug_font: Font, ui_font: Font, fallback_fonts: HashSet, + /// Source URL registered when the font was fetched (SVG export references + /// this in `@font-face` rules). + source_urls: HashMap, } impl FontStore { @@ -55,6 +58,7 @@ impl FontStore { debug_font, ui_font, fallback_fonts: HashSet::new(), + source_urls: HashMap::new(), }) } @@ -131,6 +135,133 @@ impl FontStore { pub fn get_emoji_font(&self, _size: f32) -> Option { None } + + pub fn set_source_url(&mut self, alias: &str, url: String) { + if !url.is_empty() { + self.source_urls.insert(alias.to_string(), url); + } + } + + pub fn source_url(&self, alias: &str) -> Option<&str> { + self.source_urls.get(alias).map(String::as_str) + } + + /// Builds `@font-face` CSS rules for the given registered aliases. + /// + /// Each rule references the source URL registered for the alias at load + /// time. Aliases without a registered URL are omitted. + pub fn font_face_css_for_aliases(&self, aliases: &HashSet) -> String { + let mut seen: HashSet = HashSet::new(); + let mut css = String::new(); + + for alias in aliases { + let Some(typeface) = self + .font_provider + .match_family_style(alias, skia::FontStyle::default()) + else { + continue; + }; + + let family = typeface.family_name(); + let style = typeface.font_style(); + + // Skia's SVG backend derives `` font descriptors from the + // typeface's own `SkFontStyle` using a quirky bucketed table (see + // `skia_svg_font_weight`). We must mirror it exactly here so each + // `@font-face` pairs with the `` elements that reference it; + // otherwise, when several weights of the same family coexist, the + // browser cannot match the weight and silently falls back to 400. + let weight = skia_svg_font_weight(*style.weight()); + let slant = match style.slant() { + skia::font_style::Slant::Italic => "italic", + skia::font_style::Slant::Oblique => "oblique", + _ => "normal", + }; + let stretch = skia_svg_font_stretch(*style.width()); + + let dedup_key = format!("{family}|{weight}|{slant}|{stretch:?}"); + if !seen.insert(dedup_key) { + continue; + } + + let stretch_decl = stretch + .map(|s| format!("font-stretch:{s};")) + .unwrap_or_default(); + + let Some(url) = self.source_url(alias) else { + continue; + }; + let src = font_face_src_from_url(url); + + css.push_str(&format!( + "@font-face{{font-family:\"{family}\";font-style:{slant};font-weight:{weight};{stretch_decl}src:{src};}}", + )); + } + + css + } +} + +fn font_face_src_from_url(url: &str) -> String { + let format = font_format_from_url(url); + format!("url(\"{}\") format(\"{format}\")", css_escape_url(url)) +} + +fn font_format_from_url(url: &str) -> &'static str { + let path = url + .split('#') + .next() + .unwrap_or(url) + .split('?') + .next() + .unwrap_or(url); + if path.ends_with(".woff2") { + "woff2" + } else if path.ends_with(".woff") { + "woff" + } else if path.ends_with(".otf") { + "opentype" + } else { + "truetype" + } +} + +fn css_escape_url(url: &str) -> String { + url.replace('\\', "\\\\").replace('"', "\\\"") +} + +/// Reproduces the `font-weight` string that `SkSVGDevice::addTextAttributes` +/// writes on `` elements for a given typeface weight. +fn skia_svg_font_weight(weight: i32) -> &'static str { + // Skia's table is ["100","200","300","normal","400","500","600","bold", + // "800","900"]; we substitute "400" for the omitted-normal bucket so the + // descriptor still resolves to weight 400. + const WEIGHTS: [&str; 10] = [ + "100", "200", "300", "400", "400", "500", "600", "bold", "800", "900", + ]; + let index = ((weight.clamp(100, 900) - 50) / 100) as usize; + WEIGHTS[index] +} + +/// Reproduces the `font-stretch` value `SkSVGDevice` writes for a typeface +/// width, returning `None` for the normal width (which Skia omits). +fn skia_svg_font_stretch(width: i32) -> Option<&'static str> { + const STRETCHES: [&str; 9] = [ + "ultra-condensed", + "extra-condensed", + "condensed", + "semi-condensed", + "normal", + "semi-expanded", + "expanded", + "extra-expanded", + "ultra-expanded", + ]; + let index = width - 1; + if index == 4 { + return None; + } + STRETCHES.get(usize::try_from(index).ok()?).copied() } fn load_default_provider(font_mgr: &FontMgr) -> skia::textlayout::TypefaceFontProvider { @@ -144,3 +275,37 @@ fn load_default_provider(font_mgr: &FontMgr) -> skia::textlayout::TypefaceFontPr font_provider } + +#[cfg(test)] +mod tests { + use super::*; + use crate::shapes::{FontFamily, FontStyle}; + use crate::uuid::Uuid; + + #[test] + fn font_face_css_uses_registered_url() { + let mut store = FontStore::try_new().expect("font store"); + let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal); + let alias = family.alias(); + store.set_source_url(&alias, "https://example.com/fonts/source.ttf".to_string()); + + let mut aliases = HashSet::new(); + aliases.insert(alias); + let css = store.font_face_css_for_aliases(&aliases); + + assert!(css.contains("url(\"https://example.com/fonts/source.ttf\")")); + assert!(css.contains("format(\"truetype\")")); + assert!(!css.contains("base64,")); + } + + #[test] + fn font_face_css_skips_without_registered_url() { + let store = FontStore::try_new().expect("font store"); + let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal); + let mut aliases = HashSet::new(); + aliases.insert(family.alias()); + let css = store.font_face_css_for_aliases(&aliases); + + assert!(css.is_empty()); + } +} diff --git a/render-wasm/src/render/gpu_state.rs b/render-wasm/src/render/gpu_state.rs index 0425a92f26..7a158b5fc6 100644 --- a/render-wasm/src/render/gpu_state.rs +++ b/render-wasm/src/render/gpu_state.rs @@ -7,6 +7,10 @@ use skia_safe::{self as skia, ISize}; const MIN_MAX_TEXTURE_SIZE: i32 = 512; const MAX_MAX_TEXTURE_SIZE: i32 = 4096; +/// Cap for the canvas framebuffer / backbuffer (not the tile atlas). +/// Larger than a typical viewport at DPR 2 but below sizes that would exceed +/// GPU limits when CSS dimensions are very large. +pub const MAX_SURFACE_SIZE: i32 = 8192; #[derive(Debug, Clone)] pub struct GpuState { @@ -22,7 +26,7 @@ impl GpuState { // We tweak some options to enhance performance. let mut context_options = ContextOptions::default(); - // context_options.reduce_ops_task_splitting = Enable::Yes; + context_options.reduce_ops_task_splitting = Enable::No; context_options.skip_gl_error_checks = Enable::Yes; // context_options.runtime_program_cache_size = 1024; // context_options.allow_multiple_glyph_cache_textures = Enable::Yes; @@ -57,6 +61,33 @@ impl GpuState { .clamp(MIN_MAX_TEXTURE_SIZE, MAX_MAX_TEXTURE_SIZE) } + pub fn max_surface_size(&self) -> i32 { + self.context + .max_texture_size() + .clamp(MIN_MAX_TEXTURE_SIZE, MAX_SURFACE_SIZE) + } + + /// Actual default-framebuffer size after the canvas backing store is set. + /// Browsers may allocate a smaller `drawingBuffer` than `canvas.width`; + /// wrapping Skia at the requested size then shifts content (GL origin is + /// bottom-left). Native builds have no canvas; return `None`. + pub fn drawing_buffer_size(&self) -> Option<(i32, i32)> { + #[cfg(target_arch = "wasm32")] + { + let w = crate::run_script_int!( + "(typeof GLctx!=='undefined'&&GLctx)?GLctx.drawingBufferWidth:0" + ); + let h = crate::run_script_int!( + "(typeof GLctx!=='undefined'&&GLctx)?GLctx.drawingBufferHeight:0" + ); + if w > 0 && h > 0 { + return Some((w, h)); + } + } + let _ = self; + None + } + fn delete_gl_texture(&mut self, texture_id: gl::types::GLuint) -> bool { unsafe { gl::DeleteTextures(1, &texture_id); diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index fed66505fe..727073f18e 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -11,6 +11,10 @@ const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 1; const MIN_DPR_VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 2; const MAX_BLOCKING_TIME_MS: i32 = 32; const NODE_BATCH_THRESHOLD: i32 = 3; +/// Soft-drain GPU every N walker nodes on progressive Partials. Keeps ops +/// buffers bounded when many shapes paint cheaply to Current (release packs +/// far more per budget than debug). +const PARTIAL_GPU_DRAIN_EVERY_N: i32 = 64; const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0; const ANTIALIAS_THRESHOLD: f32 = 7.0; #[derive(Debug, Copy, Clone, PartialEq)] @@ -29,6 +33,9 @@ pub struct RenderOptions { pub dpr_viewport_interest_area_threshold: i32, pub max_blocking_time_ms: i32, pub node_batch_threshold: i32, + /// Soft-flush GPU every N nodes during progressive tile walks (see + /// [`PARTIAL_GPU_DRAIN_EVERY_N`]). + pub partial_gpu_drain_every_n: i32, pub blur_downscale_threshold: f32, pub capture_frames: i32, } @@ -45,6 +52,7 @@ impl Default for RenderOptions { dpr_viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD, max_blocking_time_ms: MAX_BLOCKING_TIME_MS, node_batch_threshold: NODE_BATCH_THRESHOLD, + partial_gpu_drain_every_n: PARTIAL_GPU_DRAIN_EVERY_N, blur_downscale_threshold: BLUR_DOWNSCALE_THRESHOLD, capture_frames: 0, } @@ -113,45 +121,4 @@ impl RenderOptions { pub fn show_wasm_info(&self) -> bool { self.flags & SHOW_WASM_INFO == SHOW_WASM_INFO } - - pub fn set_antialias_threshold(&mut self, value: f32) -> bool { - if value.is_finite() && value > 0.0 { - self.antialias_threshold = value; - return true; - } - false - } - - pub fn set_blur_downscale_threshold(&mut self, value: f32) -> bool { - if value.is_finite() && value > 0.0 { - self.blur_downscale_threshold = value; - return true; - } - false - } - - pub fn set_viewport_interest_area_threshold(&mut self, value: i32) -> bool { - if value >= 0 && self.viewport_interest_area_threshold != value { - self.viewport_interest_area_threshold = value; - self.update_dpr_viewport_interest_area_threshold(); - return true; - } - false - } - - pub fn set_node_batch_threshold(&mut self, value: i32) -> bool { - if value > 0 { - self.node_batch_threshold = value; - return true; - } - false - } - - pub fn set_max_blocking_time_ms(&mut self, value: i32) -> bool { - if value > 0 { - self.max_blocking_time_ms = value; - return true; - } - false - } } diff --git a/render-wasm/src/render/shadows.rs b/render-wasm/src/render/shadows.rs index c3de172ad8..19f90542cf 100644 --- a/render-wasm/src/render/shadows.rs +++ b/render-wasm/src/render/shadows.rs @@ -1,22 +1,377 @@ +use std::collections::HashMap; + +use super::filters; use super::{RenderState, SurfaceId}; -use crate::render::strokes; -use crate::shapes::{ParagraphBuilderGroup, Shadow, Shape, Stroke, StrokeKind, TextContent, Type}; -use skia_safe::{canvas::SaveLayerRec, Paint, Path}; - use crate::error::Result; +use crate::get_resources; +use crate::render::strokes; use crate::render::text; +use crate::shapes::radius_to_sigma; +use crate::shapes::{ParagraphBuilderGroup, Shadow, Shape, Stroke, StrokeKind, TextContent, Type}; +use crate::uuid::Uuid; +use skia_safe::{self as skia, canvas::SaveLayerRec, Paint, Path, Rect}; + +// --------------------------------------------------------------------------- +// Direct frame drop shadows (fast inline blur + filter-surface cache fallback) +// --------------------------------------------------------------------------- + +pub(crate) struct DropShadowFilterCache { + entries: HashMap, +} + +#[derive(Hash, PartialEq, Eq, Clone, Copy)] +pub(crate) struct DropShadowFilterCacheKey { + shape_id: Uuid, + blur_bits: u32, + spread_bits: u32, + offset_x_bits: u32, + offset_y_bits: u32, + scale_bits: u32, + transform_a_bits: u32, + transform_b_bits: u32, + transform_c_bits: u32, + transform_d_bits: u32, + transform_e_bits: u32, + transform_f_bits: u32, + layer_blur_bits: u32, +} + +pub(crate) struct CachedDropShadowFilter { + bounds: Rect, + filter_scale: f32, + image: skia::Image, +} + +impl CachedDropShadowFilter { + pub(crate) fn new(bounds: Rect, filter_scale: f32, image: skia::Image) -> Self { + Self { + bounds, + filter_scale, + image, + } + } +} + +impl DropShadowFilterCacheKey { + pub(crate) fn for_shape( + shape_id: Uuid, + shadow: &Shadow, + scale: f32, + transform: &skia::Matrix, + layer_blur: f32, + ) -> Self { + Self::new(shape_id, shadow, scale, transform, layer_blur) + } + + fn new( + shape_id: Uuid, + shadow: &Shadow, + scale: f32, + transform: &skia::Matrix, + layer_blur: f32, + ) -> Self { + Self { + shape_id, + blur_bits: shadow.blur.to_bits(), + spread_bits: shadow.spread.to_bits(), + offset_x_bits: shadow.offset.0.to_bits(), + offset_y_bits: shadow.offset.1.to_bits(), + scale_bits: scale.to_bits(), + transform_a_bits: transform[0].to_bits(), + transform_b_bits: transform[1].to_bits(), + transform_c_bits: transform[2].to_bits(), + transform_d_bits: transform[3].to_bits(), + transform_e_bits: transform[4].to_bits(), + transform_f_bits: transform[5].to_bits(), + layer_blur_bits: layer_blur.to_bits(), + } + } +} + +impl DropShadowFilterCache { + pub fn new() -> Self { + Self { + entries: HashMap::default(), + } + } + + pub fn clear(&mut self) { + self.entries.clear(); + } + + pub(crate) fn lookup(&self, key: &DropShadowFilterCacheKey) -> Option<&CachedDropShadowFilter> { + self.entries.get(key) + } + + pub(crate) fn store(&mut self, key: DropShadowFilterCacheKey, value: CachedDropShadowFilter) { + self.entries.insert(key, value); + } +} + +/// Renders a direct frame drop shadow: inline blur on the tile when the kernel +/// fits the margin, otherwise a cached filter-surface pass shared across tiles. +/// +/// Does not apply the caller's clip stack; clip is applied when compositing +/// `DropShadows` onto the target surface. +pub(crate) fn render_direct_frame_drop_shadow( + state: &mut RenderState, + frame: &Shape, + shape_bounds: &Rect, + shadow: &Shadow, + scale: f32, +) -> Result<()> { + let margin = state.surfaces.margins().width as f32; + let sigma_device = radius_to_sigma(shadow.blur) * scale; + if sigma_device <= margin / 3.0 { + render_inline_frame_shadow(state, frame, shadow, scale) + } else { + render_cached_filter_frame_shadow(state, frame, shape_bounds, shadow, scale) + } +} + +fn frame_shadow_antialias(state: &RenderState, frame: &Shape, scale: f32) -> bool { + !state.options.is_fast_mode() + && frame.should_use_antialias(scale, state.options.antialias_threshold) +} + +fn spread_outset(spread: f32) -> Option { + Some(spread).filter(|&s| s > 0.0) +} + +fn spread_inset(spread: f32) -> Option { + Some(-spread).filter(|&s| s > 0.0) +} + +fn blur_layer_paint(blur: f32, sigma_scale: f32) -> skia::Paint { + let mut paint = skia::Paint::default(); + if blur > 0.0 { + let sigma = radius_to_sigma(blur) * sigma_scale; + paint.set_image_filter(skia::image_filters::blur((sigma, sigma), None, None, None)); + } + paint.set_blend_mode(skia::BlendMode::SrcOver); + paint +} + +fn draw_frame_shadow_rect( + surfaces: &mut super::Surfaces, + surface_id: SurfaceId, + frame: &Shape, + shadow: &Shadow, + antialias: bool, +) { + let mut fill_paint = skia::Paint::default(); + fill_paint.set_color(skia::Color::BLACK); + fill_paint.set_anti_alias(antialias); + surfaces.draw_rect_to( + surface_id, + frame, + &fill_paint, + spread_outset(shadow.spread), + spread_inset(shadow.spread), + ); +} + +fn shadow_filter_bounds( + shadow: &Shadow, + shape_bounds: &Rect, + world_offset: (f32, f32), +) -> Option { + let mut shadow_cull = *shadow; + shadow_cull.color = skia::Color::BLACK; + shadow_cull.offset = (0.0, 0.0); + let drop_filter = shadow_cull.get_drop_shadow_filter()?; + let mut bounds = drop_filter.compute_fast_bounds(*shape_bounds); + bounds.offset(world_offset); + Some(bounds) +} + +/// Local draw matrix for frame shadow geometry: centered shape transform plus +/// shadow offset in local space (matches `render_shape` with `Some(offset)`). +fn frame_shadow_draw_matrix(frame: &Shape, shadow: &Shadow) -> skia::Matrix { + let mut matrix = frame.centered_transform(); + matrix.pre_translate((shadow.offset.0, shadow.offset.1)); + matrix +} + +/// Shadow offset mapped to world space (for bounds culling and cache blit). +fn shadow_world_offset(frame: &Shape, shadow: &Shadow) -> (f32, f32) { + let mapped = frame + .centered_transform() + .map_vector((shadow.offset.0, shadow.offset.1)); + (mapped.x, mapped.y) +} + +/// When bounds fit in the filter surface, skip blur downscale to avoid banding +/// at high zoom. The tile cache makes a single full-res pass affordable. +fn blur_downscale_for_frame_shadow( + blur: f32, + bounds: Rect, + filter_width: i32, + filter_height: i32, + threshold: f32, +) -> f32 { + let bounds_w = bounds.width().ceil().max(1.0) as i32; + let bounds_h = bounds.height().ceil().max(1.0) as i32; + if bounds_w <= filter_width && bounds_h <= filter_height { + return 1.0; + } + if blur > threshold { + (threshold / blur).max(1.0 / threshold) + } else { + 1.0 + } +} + +pub(crate) fn blit_cached_drop_shadow_filter( + surfaces: &mut super::Surfaces, + cached: &CachedDropShadowFilter, + layer_blur: Option, +) { + let sampling = get_resources().sampling_options; + let mut paint = skia::Paint::default(); + if let Some(filter) = layer_blur { + paint.set_image_filter(filter); + } + let drop_canvas = surfaces.canvas(SurfaceId::DropShadows); + let dst = skia::Rect::from_wh(cached.image.width() as f32, cached.image.height() as f32); + + drop_canvas.save(); + drop_canvas.save(); + if cached.filter_scale < 1.0 { + drop_canvas.scale((1.0 / cached.filter_scale, 1.0 / cached.filter_scale)); + drop_canvas.translate(( + cached.bounds.left * cached.filter_scale, + cached.bounds.top * cached.filter_scale, + )); + } else { + drop_canvas.translate((cached.bounds.left, cached.bounds.top)); + } + drop_canvas.draw_image_rect_with_sampling_options(&cached.image, None, dst, sampling, &paint); + drop_canvas.restore(); + drop_canvas.restore(); +} + +fn render_inline_frame_shadow( + state: &mut RenderState, + frame: &Shape, + shadow: &Shadow, + scale: f32, +) -> Result<()> { + let antialias = frame_shadow_antialias(state, frame, scale); + let layer_paint = blur_layer_paint(shadow.blur, 1.0); + let draw_matrix = frame_shadow_draw_matrix(frame, shadow); + + { + let drop_canvas = state.surfaces.canvas(SurfaceId::DropShadows); + drop_canvas.save(); + drop_canvas.concat(&draw_matrix); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&layer_paint); + drop_canvas.save_layer(&layer_rec); + } + + draw_frame_shadow_rect( + &mut state.surfaces, + SurfaceId::DropShadows, + frame, + shadow, + antialias, + ); + + { + let drop_canvas = state.surfaces.canvas(SurfaceId::DropShadows); + drop_canvas.restore(); + drop_canvas.restore(); + } + + Ok(()) +} + +fn render_cached_filter_frame_shadow( + state: &mut RenderState, + frame: &Shape, + shape_bounds: &Rect, + shadow: &Shadow, + scale: f32, +) -> Result<()> { + let draw_matrix = frame.centered_transform(); + let key = DropShadowFilterCacheKey::for_shape(frame.id, shadow, scale, &draw_matrix, 0.0); + if let Some(cached) = state.drop_shadow_filter_cache.lookup(&key) { + blit_cached_drop_shadow_filter(&mut state.surfaces, cached, None); + return Ok(()); + } + + let world_offset = shadow_world_offset(frame, shadow); + let Some(bounds) = shadow_filter_bounds(shadow, shape_bounds, world_offset) else { + return Ok(()); + }; + + let antialias = frame_shadow_antialias(state, frame, scale); + let (filter_w, filter_h) = state.surfaces.filter_size(); + let blur_downscale = blur_downscale_for_frame_shadow( + shadow.blur, + bounds, + filter_w, + filter_h, + state.options.blur_downscale_threshold, + ); + let layer_paint = blur_layer_paint(shadow.blur, blur_downscale); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&layer_paint); + + let shadow_draw_matrix = frame_shadow_draw_matrix(frame, shadow); + let filter_result = filters::render_into_filter_surface( + state, + bounds, + blur_downscale, + |state, temp_surface| { + { + let canvas = state.surfaces.canvas(temp_surface); + canvas.save(); + canvas.concat(&shadow_draw_matrix); + canvas.save_layer(&layer_rec); + } + draw_frame_shadow_rect(&mut state.surfaces, temp_surface, frame, shadow, antialias); + { + let canvas = state.surfaces.canvas(temp_surface); + canvas.restore(); + canvas.restore(); + } + Ok(()) + }, + )?; + + if let Some((mut surface, filter_scale)) = filter_result { + let cached = CachedDropShadowFilter { + bounds, + filter_scale, + image: surface.image_snapshot(), + }; + blit_cached_drop_shadow_filter(&mut state.surfaces, &cached, None); + state.drop_shadow_filter_cache.store(key, cached); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Inner / text shadows +// --------------------------------------------------------------------------- -// Fill Shadows pub fn render_fill_inner_shadows( render_state: &mut RenderState, shape: &Shape, antialias: bool, surface_id: SurfaceId, ) { - if shape.has_fills() { - for shadow in shape.inner_shadows_visible() { - render_fill_inner_shadow(render_state, shape, shadow, antialias, surface_id); + if !shape.has_fills() || render_state.should_skip_drop_shadows() { + return; + } + let scale = render_state.get_scale(); + let recursive = shape.is_recursive(); + for shadow in shape.inner_shadows_visible() { + if !shadow.is_perceptible_at_scale_for(scale, recursive) { + continue; } + render_fill_inner_shadow(render_state, shape, shadow, antialias, surface_id); } } @@ -38,19 +393,25 @@ pub fn render_stroke_inner_shadows( antialias: bool, surface_id: SurfaceId, ) -> Result<()> { - if !shape.has_fills() { - for shadow in shape.inner_shadows_visible() { - let filter = shadow.get_inner_shadow_filter(); - strokes::render_single( - render_state, - shape, - stroke, - Some(surface_id), - filter.as_ref(), - antialias, - None, // Inner shadows don't use spread - )?; + if shape.has_fills() || render_state.should_skip_drop_shadows() { + return Ok(()); + } + let scale = render_state.get_scale(); + let recursive = shape.is_recursive(); + for shadow in shape.inner_shadows_visible() { + if !shadow.is_perceptible_at_scale_for(scale, recursive) { + continue; } + let filter = shadow.get_inner_shadow_filter(); + strokes::render_single( + render_state, + shape, + stroke, + Some(surface_id), + filter.as_ref(), + antialias, + None, // Inner shadows don't use spread + )?; } Ok(()) } diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 5e5901d53a..b4ac78eb05 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -561,6 +561,13 @@ fn draw_image_stroke_in_container( surface_id: SurfaceId, ) -> Result<()> { let scale = render_state.get_scale(); + let lod_stroke; + let stroke = if matches!(shape.shape_type, Type::Path(_) | Type::Bool(_)) { + lod_stroke = stroke.path_lod_at_scale(shape.is_open(), scale); + &lod_stroke + } else { + stroke + }; let Some(image) = get_resources().images.get(&image_fill.id()) else { return Ok(()); }; @@ -938,12 +945,13 @@ fn render_merged( shape_type @ (Type::Path(_) | Type::Bool(_)) => { if let Some(path) = shape_type.path() { let is_open = path.is_open(); + let lod_stroke = representative.path_lod_at_scale(is_open, scale); let mut paint = - representative.to_stroked_paint(is_open, &selrect, svg_attrs, antialias); + lod_stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias); paint.set_shader(merged.shader()); draw_stroke_on_path( canvas, - representative, + &lod_stroke, path, &paint, path_transform.as_ref(), @@ -1097,6 +1105,8 @@ fn render_single_internal( shape_type @ (Type::Path(_) | Type::Bool(_)) => { if let Some(path) = shape_type.path() { let is_open = path.is_open(); + let lod_stroke = stroke.path_lod_at_scale(is_open, scale); + let stroke = &lod_stroke; let mut paint = stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias); // Apply outset by increasing stroke width diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index e68c0df0c3..36e5c02008 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -27,6 +27,29 @@ const TILE_DRAWABLE_RECT: IRect = IRect { }; const DOC_ATLAS_MAX_DIM: i32 = 4096; +/// GPU→GPU copy of `src` from `from` into `dst` on `to_canvas`, without +/// `image_snapshot` (avoids per-tile sync stalls on WebGL). +fn draw_surface_src_rect_to_dst( + from: &mut skia::Surface, + to_canvas: &skia::Canvas, + src: skia::Rect, + dst: skia::Rect, + sampling: skia::SamplingOptions, +) { + if src.is_empty() || dst.is_empty() { + return; + } + to_canvas.save(); + to_canvas.clip_rect(dst, None, true); + let sx = dst.width() / src.width(); + let sy = dst.height() / src.height(); + to_canvas.translate((dst.left, dst.top)); + to_canvas.scale((sx, sy)); + to_canvas.translate((-src.left, -src.top)); + from.draw(to_canvas, (0.0, 0.0), sampling, None); + to_canvas.restore(); +} + pub fn get_cache_size(viewbox: &Viewbox, interest: i32) -> skia::ISize { // First we retrieve the extended area of the viewport that we could render. let TileRect(isx, isy, iex, iey) = @@ -69,6 +92,29 @@ pub enum SurfaceId { TileAtlas = 0b100_0000_1000, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TileCacheKey { + pub tile: Tile, + pub scale_bits: u32, +} + +impl TileCacheKey { + pub fn new(tile: Tile, scale: f32) -> Self { + Self { + tile, + scale_bits: scale.to_bits(), + } + } + + pub fn matches_scale(&self, scale: f32) -> bool { + self.scale_bits == scale.to_bits() + } + + pub fn scale(&self) -> f32 { + f32::from_bits(self.scale_bits) + } +} + pub struct DocAtlas { // Persistent 1:1 document-space atlas that gets incrementally updated as tiles render. // It grows dynamically to include any rendered document rect. @@ -82,9 +128,9 @@ pub struct DocAtlas { /// Optional document-space bounds (1 unit == 1 doc px @ 100% zoom) used to /// clamp atlas writes/clears so the atlas doesn't grow due to outlier tile rects. pub doc_bounds: Option, - /// Tracks the last document-space rect written to the atlas per tile. - /// Used to clear old content without clearing the whole (potentially huge) tile rect. - pub tile_doc_rects: HashMap, + /// Last atlas write per (tile, scale). Scale is part of the key so zoom + /// levels do not overwrite each other's placement metadata. + pub tile_doc_rects: HashMap, } impl DocAtlas { @@ -238,17 +284,20 @@ impl DocAtlas { Ok(()) } - fn blit_tile_image_into_atlas( + /// Blit a Current-surface drawable rect into the doc atlas without + /// `image_snapshot` (GPU→GPU draw; avoids per-tile sync stalls). + fn blit_current_drawable_into_atlas( &mut self, gpu_state: &mut GpuState, - tile_image: &skia::Image, + current: &mut skia::Surface, + drawable_src: skia::Rect, tile_doc_rect: skia::Rect, + sampling: skia::SamplingOptions, ) -> Result<()> { - if tile_doc_rect.is_empty() { + if tile_doc_rect.is_empty() || drawable_src.is_empty() { return Ok(()); } - // Clamp to document bounds (if any) and compute a matching source-rect in tile pixels. let mut clipped_doc_rect = tile_doc_rect; if let Some(bounds) = self.doc_bounds { if !clipped_doc_rect.intersect(bounds) { @@ -261,7 +310,6 @@ impl DocAtlas { self.ensure_atlas_contains(gpu_state, clipped_doc_rect)?; - // Destination is document-space rect mapped into atlas pixel coords. let dst = skia::Rect::from_xywh( (clipped_doc_rect.left - self.origin.x) * self.scale, (clipped_doc_rect.top - self.origin.y) * self.scale, @@ -269,24 +317,18 @@ impl DocAtlas { clipped_doc_rect.height() * self.scale, ); - // Compute source rect in tile_image pixel coordinates. - let img_w = tile_image.width() as f32; - let img_h = tile_image.height() as f32; let tw = tile_doc_rect.width().max(1.0); let th = tile_doc_rect.height().max(1.0); - - let sx = ((clipped_doc_rect.left - tile_doc_rect.left) / tw) * img_w; - let sy = ((clipped_doc_rect.top - tile_doc_rect.top) / th) * img_h; - let sw = (clipped_doc_rect.width() / tw) * img_w; - let sh = (clipped_doc_rect.height() / th) * img_h; - let src = skia::Rect::from_xywh(sx, sy, sw, sh); - - self.surface.canvas().draw_image_rect( - tile_image, - Some((&src, skia::canvas::SrcRectConstraint::Fast)), - dst, - &skia::Paint::default(), + let dw = drawable_src.width(); + let dh = drawable_src.height(); + let src = skia::Rect::from_xywh( + drawable_src.left + ((clipped_doc_rect.left - tile_doc_rect.left) / tw) * dw, + drawable_src.top + ((clipped_doc_rect.top - tile_doc_rect.top) / th) * dh, + (clipped_doc_rect.width() / tw) * dw, + (clipped_doc_rect.height() / th) * dh, ); + + draw_surface_src_rect_to_dst(current, self.surface.canvas(), src, dst, sampling); Ok(()) } @@ -370,13 +412,17 @@ impl DocAtlas { Ok(()) } - /// Clears the last atlas region written by `tile` (if any). + /// Clears the last atlas region written by `key` (if any). /// /// This avoids clearing the entire logical tile rect which, at very low /// zoom levels, can be enormous in document space and would unnecessarily /// grow / rescale the atlas. - pub fn clear_tile_in_atlas(&mut self, gpu_state: &mut GpuState, tile: Tile) -> Result<()> { - if let Some(doc_rect) = self.tile_doc_rects.remove(&tile) { + pub fn clear_tile_in_atlas( + &mut self, + gpu_state: &mut GpuState, + key: TileCacheKey, + ) -> Result<()> { + if let Some(doc_rect) = self.tile_doc_rects.remove(&key) { self.clear_doc_rect_in_atlas(gpu_state, doc_rect)?; } Ok(()) @@ -484,8 +530,7 @@ impl Surfaces { let ui = gpu_state.create_surface_with_dimensions("ui".to_string(), width, height)?; let debug = gpu_state.create_surface_with_dimensions("debug".to_string(), width, height)?; - // 512, why not? - let tiles = TileTextureCache::new(tile_atlas.width(), 512); + let tiles = TileTextureCache::new(tile_atlas.width(), tile_atlas.height()); let atlas = DocAtlas::try_new()?; Ok(Self { target, @@ -517,6 +562,20 @@ impl Surfaces { }) } + /// Pack `needed_slots` into the existing 4096 atlas by shrinking the + /// physical cell size. No-op when the layout already fits. + pub fn ensure_tile_atlas_layout(&mut self, needed_slots: usize) { + let atlas_px = self.tile_atlas.width().min(self.tile_atlas.height()); + let slot = tiles::tile_atlas_slot_size(needed_slots, atlas_px); + if slot == self.tiles.slot_size() { + return; + } + self.tiles + .repack(self.tile_atlas.width(), self.tile_atlas.height(), slot); + self.tile_atlas.canvas().clear(skia::Color::TRANSPARENT); + self.tile_atlas_image = None; + } + pub fn set_dpr(&mut self, dpr: f32) { self.dpr = dpr; } @@ -525,6 +584,14 @@ impl Surfaces { self.tiles.clear(); } + fn tile_atlas_sampling(&self) -> skia::SamplingOptions { + if self.tiles.slot_size() < TILE_SIZE { + skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::None) + } else { + self.atlas_sampling_options + } + } + pub fn draw_tile_atlas_to_backbuffer( &mut self, viewbox: &Viewbox, @@ -539,6 +606,7 @@ impl Surfaces { let Some(atlas_image) = self.tile_atlas_image.as_ref() else { return; }; + let sampling = self.tile_atlas_sampling(); let canvas = self.backbuffer.canvas(); canvas.clear(background); canvas.draw_atlas( @@ -547,7 +615,7 @@ impl Surfaces { &self.tiles.textures, None, skia::BlendMode::SrcOver, - self.atlas_sampling_options, + sampling, None, None, ); @@ -624,6 +692,7 @@ impl Surfaces { let Some(atlas_image) = self.tile_atlas_image.as_ref() else { return; }; + let sampling = self.tile_atlas_sampling(); let canvas = self.backbuffer.canvas(); canvas.save(); @@ -635,7 +704,7 @@ impl Surfaces { &batch.textures, None, skia::BlendMode::SrcOver, - self.atlas_sampling_options, + sampling, None, None, ); @@ -827,28 +896,24 @@ impl Surfaces { pub fn update_render_context(&mut self, render_area: skia::Rect, scale: f32) { let translation = self.get_render_context_translation(render_area, scale); - // When context changes (zoom/pan/tile), clear all render surfaces first - // to remove any residual content from previous tiles, then mark as dirty - // so they get redrawn with new transformations + // When context changes (zoom/pan/tile), clear intermediate surfaces so + // residual content from the previous tile cannot leak into the next. let surface_ids = SurfaceId::Fills as u32 | SurfaceId::Strokes as u32 | SurfaceId::InnerShadows as u32 | SurfaceId::TextDropShadows as u32 | SurfaceId::DropShadows as u32; - // Clear surfaces before updating transformations to remove residual content self.apply_mut(surface_ids, |s| { s.canvas().clear(skia::Color::TRANSPARENT); }); - // Mark all render surfaces as dirty so they get redrawn - self.mark_dirty(SurfaceId::Fills); - self.mark_dirty(SurfaceId::Strokes); - self.mark_dirty(SurfaceId::InnerShadows); - self.mark_dirty(SurfaceId::TextDropShadows); - self.mark_dirty(SurfaceId::DropShadows); + // Dirty means "has content to composite", not "transform was updated". + // After a clear the surfaces are empty; leaving them dirty made the + // first `draw_shape_surface_stack_into` on each tile blit empty + // Fills/Strokes/shadows into Current (useless GPU ops / ops-task noise). + self.clear_dirty(surface_ids); - // Update transformations self.apply_mut(surface_ids, |s| { let canvas = s.canvas(); canvas.reset_matrix(); @@ -1189,6 +1254,7 @@ impl Surfaces { canvas.restore(); } + #[allow(clippy::too_many_arguments)] pub fn draw_current_tile_into_tile_atlas( &mut self, tile_viewbox: &TileViewbox, @@ -1196,42 +1262,49 @@ impl Surfaces { tile_rect: &skia::Rect, skip_cache_surface: bool, tile_doc_rect: skia::Rect, + scale: f32, + view_doc: skia::Rect, ) { let gpu_state = get_gpu_state(); - let rect = TILE_DRAWABLE_RECT; + let src = skia::Rect::from(TILE_DRAWABLE_RECT); + let sampling = self.sampling_options; - let tile_image_opt = self.current.image_snapshot_with_bounds(rect); - if let Some(tile_image) = tile_image_opt { - if !skip_cache_surface { - // Draw to cache surface for render_from_cache - self.cache.canvas().draw_image_rect( - &tile_image, - None, - tile_rect, - &skia::Paint::default(), - ); - } + // DocAtlas + tile atlas via Surface::draw (no image_snapshot sync). + let _ = self.atlas.blit_current_drawable_into_atlas( + gpu_state, + &mut self.current, + src, + tile_doc_rect, + sampling, + ); + let key = TileCacheKey::new(*tile, scale); + self.atlas.tile_doc_rects.insert(key, tile_doc_rect); - // Incrementally update persistent 1:1 atlas in document space. - // `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%). - let _ = self - .atlas - .blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect); - self.atlas.tile_doc_rects.insert(*tile, tile_doc_rect); + let mut tile_doc_rects = std::mem::take(&mut self.atlas.tile_doc_rects); + let tile_ref = self + .tiles + .add(tile_viewbox, tile, scale, view_doc, &mut tile_doc_rects); + self.atlas.tile_doc_rects = tile_doc_rects; + let dst = tile_ref.rect; + let mut current = self.current.clone(); + draw_surface_src_rect_to_dst(&mut current, self.tile_atlas.canvas(), src, dst, sampling); - // Draws current tile into tile atlas - let tile_ref = self.tiles.add(tile_viewbox, tile); - self.tile_atlas.canvas().draw_image_rect( - &tile_image, - None, - tile_ref.rect, - &skia::Paint::default(), + if !skip_cache_surface { + // Optional legacy Cache surface fill (debug). Pan/zoom preview + // uses DocAtlas + tile-atlas textures via render_from_cache. + let mut current = self.current.clone(); + draw_surface_src_rect_to_dst( + &mut current, + self.cache.canvas(), + src, + *tile_rect, + sampling, ); } } - pub fn has_cached_tile_surface(&self, tile: Tile) -> bool { - self.tiles.has(tile) + pub fn has_cached_tile_surface(&self, tile: Tile, scale: f32) -> bool { + self.tiles.has(tile, scale) } /// Builds a 1:1 workspace-pixel snapshot for `src_doc_bounds` / `src_irect` into @@ -1288,7 +1361,7 @@ impl Surfaces { (clip_doc.bottom - vb_top) * scale - iy0, ); - if let Some(tile_ref) = self.tiles.get(tile) { + if let Some(tile_ref) = self.tiles.get(tile, scale) { let bounds = skia::IRect::from_ltrb( tile_ref.rect.left as i32, tile_ref.rect.top as i32, @@ -1349,7 +1422,47 @@ impl Surfaces { self.tiles.remove(tile); // Also clear the corresponding region in the persistent atlas to avoid // leaving stale pixels when shapes move/delete. - let _ = self.atlas.clear_tile_in_atlas(gpu_state, tile); + let keys: Vec<_> = self + .atlas + .tile_doc_rects + .keys() + .copied() + .filter(|key| key.tile == tile) + .collect(); + for key in keys { + let _ = self.atlas.clear_tile_in_atlas(gpu_state, key); + } + } + + /// Drop one (tile, scale) entry; leave other zoom levels alone. + pub fn remove_cached_tile_surface_at(&mut self, tile: Tile, scale: f32) { + let gpu_state = get_gpu_state(); + self.tiles.remove_at(tile, scale); + let key = TileCacheKey::new(tile, scale); + let _ = self.atlas.clear_tile_in_atlas(gpu_state, key); + } + + /// Evict every cached tile whose stored doc rect intersects `doc_rect`. + pub fn invalidate_cached_tiles_intersecting(&mut self, doc_rect: skia::Rect) { + if doc_rect.is_empty() { + return; + } + + let keys: Vec = self + .atlas + .tile_doc_rects + .iter() + .filter_map(|(key, rect)| { + (!rect.is_empty() && rect.intersects(doc_rect)).then_some(*key) + }) + .collect(); + + let gpu_state = get_gpu_state(); + for key in keys { + self.tiles.remove_at(key.tile, key.scale()); + let _ = self.atlas.clear_tile_in_atlas(gpu_state, key); + } + self.atlas.clear_doc_rect_in_atlas_clipped(doc_rect); } /// Draws the current tile directly to the backbuffer and cache surfaces without @@ -1500,21 +1613,24 @@ pub struct TileAtlasTextureProvider { } impl TileAtlasTextureProvider { - pub fn new(texture_size: i32, tile_size: i32) -> Self { - let side = texture_size / tile_size; - let length = side * side; - let mut rects = Vec::with_capacity(length as usize); - for i in 0..length { - let left = (i % side) as f32 * tile_size as f32; - let top = (i / side) as f32 * tile_size as f32; - let right = left + tile_size as f32; - let bottom = top + tile_size as f32; - rects.push(Rect::new(left, top, right, bottom)); + pub fn new(texture_width: i32, texture_height: i32, tile_size: i32) -> Self { + let cols = texture_width / tile_size; + let rows = texture_height / tile_size; + let length = (cols * rows) as usize; + let mut rects = Vec::with_capacity(length); + for row in 0..rows { + for col in 0..cols { + let left = col as f32 * tile_size as f32; + let top = row as f32 * tile_size as f32; + let right = left + tile_size as f32; + let bottom = top + tile_size as f32; + rects.push(Rect::new(left, top, right, bottom)); + } } Self { index: 0, - length: length as usize, - in_use: vec![false; length as usize], + length, + in_use: vec![false; length], rects, } } @@ -1548,12 +1664,13 @@ impl TileAtlasTextureProvider { pub struct TileTextureCache { tile_size: f32, + slot_size: i32, is_updated: bool, provider: TileAtlasTextureProvider, transforms: Vec, textures: Vec, - grid: HashMap, - removed: HashSet, + grid: HashMap, + removed: HashSet, } pub struct AtlasDrawBatch { @@ -1568,11 +1685,13 @@ impl AtlasDrawBatch { } impl TileTextureCache { - pub fn new(texture_size: i32, capacity: usize) -> Self { + pub fn new(texture_width: i32, texture_height: i32) -> Self { + let capacity = ((texture_width / TILE_SIZE) * (texture_height / TILE_SIZE)) as usize; Self { tile_size: tiles::TILE_SIZE, + slot_size: TILE_SIZE, is_updated: false, - provider: TileAtlasTextureProvider::new(texture_size, TILE_SIZE), + provider: TileAtlasTextureProvider::new(texture_width, texture_height, TILE_SIZE), transforms: Vec::with_capacity(capacity), textures: Vec::with_capacity(capacity), grid: HashMap::with_capacity(capacity), @@ -1580,10 +1699,44 @@ impl TileTextureCache { } } + pub fn slot_size(&self) -> i32 { + self.slot_size + } + + fn dest_scale(&self) -> f32 { + tiles::tile_atlas_compose_scale(self.slot_size) + } + + fn compose_src_rect(&self, rect: Rect) -> Rect { + if self.slot_size < TILE_SIZE { + let inset = tiles::TILE_ATLAS_SAMPLE_INSET; + Rect::new( + rect.left + inset, + rect.top + inset, + rect.right - inset, + rect.bottom - inset, + ) + } else { + rect + } + } + + pub fn repack(&mut self, texture_width: i32, texture_height: i32, slot_size: i32) { + let capacity = ((texture_width / slot_size) * (texture_height / slot_size)) as usize; + self.slot_size = slot_size; + self.is_updated = true; + self.provider = TileAtlasTextureProvider::new(texture_width, texture_height, slot_size); + self.transforms = Vec::with_capacity(capacity); + self.textures = Vec::with_capacity(capacity); + self.grid = HashMap::with_capacity(capacity); + self.removed = HashSet::with_capacity(capacity); + } + fn gc(&mut self) { - // Make a real remove - for tile in self.removed.iter() { - if let Some(tile_ref) = self.grid.remove(tile) { + // Drain so soft-deleted keys cannot accumulate forever (scale bits make + // TileCacheKey rarely repeat across zoom levels). + for key in self.removed.drain() { + if let Some(tile_ref) = self.grid.remove(&key) { self.provider.deallocate(tile_ref); } } @@ -1597,32 +1750,61 @@ impl TileTextureCache { self.is_updated = false; } - fn gc_non_visible(&mut self, tile_viewbox: &TileViewbox) { - let marked: Vec<_> = self - .grid - .iter_mut() - .filter_map(|(tile, _)| { - if !tile_viewbox.is_visible(tile) { - Some(*tile) - } else { - None - } - }) - .take(TEXTURES_BATCH_DELETE) - .collect(); + fn gc_non_visible( + &mut self, + tile_viewbox: &TileViewbox, + scale: f32, + view_doc: skia::Rect, + tile_doc_rects: &mut HashMap, + ) { + // Evict by document coverage, not grid index: other-scale tiles can + // still cover the viewport even when their index is outside visible_rect. + let mut offscreen = Vec::new(); + let mut other_scale_onscreen = Vec::new(); - for tile in marked.iter() { - if let Some(tile_ref) = self.grid.remove(tile) { + for key in self.grid.keys() { + if self.removed.contains(key) { + continue; + } + if key.matches_scale(scale) && tile_viewbox.is_visible(&key.tile) { + continue; + } + + let intersects = tile_doc_rects + .get(key) + .is_some_and(|doc_rect| !doc_rect.is_empty() && doc_rect.intersects(view_doc)); + + if intersects { + if !key.matches_scale(scale) { + other_scale_onscreen.push(*key); + } + } else { + offscreen.push(*key); + } + } + + let mut marked = Vec::with_capacity(TEXTURES_BATCH_DELETE); + marked.extend(offscreen.into_iter().take(TEXTURES_BATCH_DELETE)); + if marked.len() < TEXTURES_BATCH_DELETE { + let remaining = TEXTURES_BATCH_DELETE - marked.len(); + marked.extend(other_scale_onscreen.into_iter().take(remaining)); + } + + for key in marked.iter() { + if let Some(tile_ref) = self.grid.remove(key) { self.provider.deallocate(tile_ref); } + tile_doc_rects.remove(key); } } pub fn update(&mut self, viewbox: &Viewbox, tile_viewbox: &TileViewbox) { + let dest_scale = self.dest_scale(); + let scale = viewbox.get_scale(); if self.transforms.len() != tile_viewbox.visible_rect.len() as usize { self.transforms.resize( tile_viewbox.visible_rect.len() as usize, - skia::RSXform::new(1.0, 0.0, Point::default()), + skia::RSXform::new(dest_scale, 0.0, Point::default()), ); } @@ -1641,26 +1823,28 @@ impl TileTextureCache { let mut index = 0; for y in tile_viewbox.visible_rect.top()..=tile_viewbox.visible_rect.bottom() { for x in tile_viewbox.visible_rect.left()..=tile_viewbox.visible_rect.right() { - let tile = Tile(x, y); + let key = TileCacheKey::new(Tile(x, y), scale); - let Some(tile_ref) = self.grid.get(&tile) else { + let Some(tile_ref) = self.grid.get(&key) else { continue; }; - if self.removed.contains(&tile) { + if self.removed.contains(&key) { continue; } - self.transforms[index].tx = x as f32 * self.tile_size - offset.x; - self.transforms[index].ty = y as f32 * self.tile_size - offset.y; - - self.textures[index].set_ltrb( - tile_ref.rect.left, - tile_ref.rect.top, - tile_ref.rect.right, - tile_ref.rect.bottom, + self.transforms[index] = skia::RSXform::new( + dest_scale, + 0.0, + ( + (x as f32 * self.tile_size - offset.x).round(), + (y as f32 * self.tile_size - offset.y).round(), + ), ); + let src = self.compose_src_rect(tile_ref.rect); + self.textures[index].set_ltrb(src.left, src.top, src.right, src.bottom); + index += 1; } } @@ -1670,7 +1854,7 @@ impl TileTextureCache { &self, viewbox: &Viewbox, tile_viewbox: &TileViewbox, - tile_doc_rects: &HashMap, + tile_doc_rects: &HashMap, ) -> AtlasDrawBatch { let mut transforms = Vec::new(); let mut textures = Vec::new(); @@ -1680,54 +1864,61 @@ impl TileTextureCache { for y in tile_viewbox.visible_rect.top()..=tile_viewbox.visible_rect.bottom() { for x in tile_viewbox.visible_rect.left()..=tile_viewbox.visible_rect.right() { - let tile = Tile(x, y); + let key = TileCacheKey::new(Tile(x, y), s); - let Some(tile_ref) = self.grid.get(&tile) else { + let Some(tile_ref) = self.grid.get(&key) else { continue; }; - if self.removed.contains(&tile) { + if self.removed.contains(&key) { continue; } let doc_rect = tile_doc_rects - .get(&tile) + .get(&key) .copied() - .unwrap_or_else(|| tiles::get_tile_rect(tile, s)); + .unwrap_or_else(|| tiles::get_tile_rect(key.tile, s)); if doc_rect.is_empty() || !doc_rect.intersects(view_doc) { continue; } - let scos = doc_rect.width() * s / self.tile_size; - let tx = (doc_rect.left + viewbox.pan.x) * s; - let ty = (doc_rect.top + viewbox.pan.y) * s; + let src = self.compose_src_rect(tile_ref.rect); + let scos = doc_rect.width() * s / src.width(); + let tx = ((doc_rect.left + viewbox.pan.x) * s).round(); + let ty = ((doc_rect.top + viewbox.pan.y) * s).round(); transforms.push(skia::RSXform::new(scos, 0.0, (tx, ty))); - textures.push(tile_ref.rect); + textures.push(src); } } - // Cached tiles from a previous zoom level use indices outside visible_rect; - // place them via their stored document rect, not the current grid walk above. - for (&tile, tile_ref) in &self.grid { - if tile_viewbox.is_visible(&tile) || self.removed.contains(&tile) { + // Other-scale / off-grid tiles: place via stored doc rect (not current scale). + for (&key, tile_ref) in &self.grid { + if self.removed.contains(&key) { + continue; + } + let visible = tile_viewbox.is_visible(&key.tile); + if key.matches_scale(s) && visible { + continue; + } + if !key.matches_scale(s) && visible && self.has(key.tile, s) { continue; } - let doc_rect = tile_doc_rects - .get(&tile) - .copied() - .unwrap_or_else(|| tiles::get_tile_rect(tile, s)); + let Some(doc_rect) = tile_doc_rects.get(&key).copied() else { + continue; + }; if doc_rect.is_empty() || !doc_rect.intersects(view_doc) { continue; } - let tx = (doc_rect.left + viewbox.pan.x) * s; - let ty = (doc_rect.top + viewbox.pan.y) * s; - let scos = doc_rect.width() * s / self.tile_size; + let src = self.compose_src_rect(tile_ref.rect); + let tx = ((doc_rect.left + viewbox.pan.x) * s).round(); + let ty = ((doc_rect.top + viewbox.pan.y) * s).round(); + let scos = doc_rect.width() * s / src.width(); transforms.push(skia::RSXform::new(scos, 0.0, (tx, ty))); - textures.push(tile_ref.rect); + textures.push(src); } AtlasDrawBatch { @@ -1736,11 +1927,19 @@ impl TileTextureCache { } } - pub fn has(&self, tile: Tile) -> bool { - self.grid.contains_key(&tile) && !self.removed.contains(&tile) + pub fn has(&self, tile: Tile, scale: f32) -> bool { + let key = TileCacheKey::new(tile, scale); + self.grid.contains_key(&key) && !self.removed.contains(&key) } - pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile) -> TileAtlasTextureRef { + pub fn add( + &mut self, + tile_viewbox: &TileViewbox, + tile: &Tile, + scale: f32, + view_doc: skia::Rect, + tile_doc_rects: &mut HashMap, + ) -> TileAtlasTextureRef { // Evict against the real slot count (`provider.length`), not the // hardcoded capacity — otherwise the guard never fires and the atlas // fills up until `allocate()` has no slot left. @@ -1748,38 +1947,67 @@ impl TileTextureCache { if self.grid.len() >= capacity { self.gc(); - self.gc_non_visible(tile_viewbox); + self.gc_non_visible(tile_viewbox, scale, view_doc, tile_doc_rects); } let Some(tile_ref) = self.provider.allocate() else { panic!("Tile texture allocation failed {}:{}", tile.0, tile.1); }; + self.insert(TileCacheKey::new(*tile, scale), tile_ref) + } - self.grid.insert(*tile, tile_ref.clone()); + fn insert(&mut self, key: TileCacheKey, tile_ref: TileAtlasTextureRef) -> TileAtlasTextureRef { + if let Some(old_ref) = self.grid.insert(key, tile_ref.clone()) { + self.provider.deallocate(old_ref); + } - if self.removed.contains(tile) { - self.removed.remove(tile); + if self.removed.contains(&key) { + self.removed.remove(&key); } self.is_updated = true; - tile_ref.clone() + tile_ref } - pub fn get(&mut self, tile: Tile) -> Option<&TileAtlasTextureRef> { - if self.removed.contains(&tile) { + pub fn get(&mut self, tile: Tile, scale: f32) -> Option<&TileAtlasTextureRef> { + let key = TileCacheKey::new(tile, scale); + if self.removed.contains(&key) { return None; } - self.grid.get(&tile) + self.grid.get(&key) } pub fn remove(&mut self, tile: Tile) { - if let Some(tile_ref) = self.grid.get(&tile) { - if tile_ref.index < self.textures.len() { - self.textures[tile_ref.index].set_empty(); + let keys: Vec<_> = self + .grid + .keys() + .copied() + .filter(|key| key.tile == tile) + .collect(); + if keys.is_empty() { + return; + } + for key in keys { + if let Some(tile_ref) = self.grid.get(&key) { + if tile_ref.index < self.textures.len() { + self.textures[tile_ref.index].set_empty(); + } } + self.removed.insert(key); } self.is_updated = true; - self.removed.insert(tile); + } + + pub fn remove_at(&mut self, tile: Tile, scale: f32) { + let key = TileCacheKey::new(tile, scale); + let Some(tile_ref) = self.grid.get(&key) else { + return; + }; + if tile_ref.index < self.textures.len() { + self.textures[tile_ref.index].set_empty(); + } + self.removed.insert(key); + self.is_updated = true; } pub fn clear(&mut self) { diff --git a/render-wasm/src/render/svg/document.rs b/render-wasm/src/render/svg/document.rs new file mode 100644 index 0000000000..083c08f920 --- /dev/null +++ b/render-wasm/src/render/svg/document.rs @@ -0,0 +1,286 @@ +use skia_safe::{self as skia, Paint}; + +use crate::shapes::{Shape, Type}; +use crate::state::ShapesPoolRef; + +use crate::render::vector::draw_shape_geometry; + +// Skia's SVG backend (`SkSVGDevice`) silently drops everything drawn inside a +// `save_layer`, so composite effects rendered with `save_layer` (opacity, +// blend mode, …) vanish in SVG. +// +// Instead of one canvas, the SVG path composes the document itself: leaf +// content is drawn into short-lived `skia::svg::Canvas` fragments (real +// ``/``/… vector markup), and composite effects become native SVG +// `` wrappers (`opacity`, `mix-blend-mode`, `clip-path`). + +/// Accumulates the SVG document body while drawing. +pub(crate) struct SvgLayerCanvas { + pub(super) scale: f32, + page_rect: skia::Rect, + tx: f32, + ty: f32, + pub(super) out: String, + pub(super) defs: String, + pending: Option, + next_id: usize, + frag_no: usize, +} + +impl SvgLayerCanvas { + pub(super) fn new(scale: f32, page_rect: skia::Rect, tx: f32, ty: f32) -> Self { + Self { + scale, + page_rect, + tx, + ty, + out: String::new(), + defs: String::new(), + pending: None, + next_id: 0, + frag_no: 0, + } + } + + pub(super) fn unique(&mut self, prefix: &str) -> String { + let id = format!("{prefix}{}", self.next_id); + self.next_id += 1; + id + } + + /// Creates a fragment canvas configured with the page transform + /// (scale + translate to the export bounds). + pub(super) fn new_fragment(&self) -> skia::svg::Canvas { + let canvas = skia::svg::Canvas::new(self.page_rect, None); + { + let cv: &skia::Canvas = &canvas; + cv.scale((self.scale, self.scale)); + cv.translate((self.tx, self.ty)); + } + canvas + } + + /// Returns the current leaf-drawing canvas, creating a fragment if needed. + pub(crate) fn canvas(&mut self) -> &skia::Canvas { + if self.pending.is_none() { + self.pending = Some(self.new_fragment()); + } + self.pending.as_deref().unwrap() + } + + /// Finalizes the pending fragment and appends its markup to `out`. + pub(super) fn flush(&mut self) { + let Some(canvas) = self.pending.take() else { + return; + }; + let data = canvas.end(); + let doc = String::from_utf8_lossy(data.as_bytes()); + let inner = extract_inner_svg(&doc); + if inner.trim().is_empty() { + return; + } + let prefix = format!("f{}_", self.frag_no); + self.frag_no += 1; + self.out + .push_str(&sanitize_skia_svg_fragment(&remap_ids(inner, &prefix))); + } + + pub(super) fn open_group(&mut self, attrs: &str) { + self.flush(); + self.out.push_str("'); + } + + pub(super) fn close_group(&mut self) { + self.flush(); + self.out.push_str(""); + } + + /// Emits a `` from a shape's geometry (in device/page space). + /// + /// A mask can be a group too. Since a group has no geometry of its own, we + /// recurse into its descendants and accumulate their geometry. + pub(super) fn push_clip_path(&mut self, id: &str, shape: &Shape, tree: ShapesPoolRef) { + let canvas = self.new_fragment(); + { + let cv: &skia::Canvas = &canvas; + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_color(skia::Color::BLACK); + draw_clip_geometry(cv, shape, tree, &paint); + } + self.finish_clip_path_fragment(id, canvas); + } + + /// Finalizes a fragment canvas as a `` def. + pub(super) fn finish_clip_path_fragment(&mut self, id: &str, canvas: skia::svg::Canvas) { + let data = canvas.end(); + let doc = String::from_utf8_lossy(data.as_bytes()); + let inner = extract_inner_svg(&doc); + let prefix = format!("f{}_", self.frag_no); + self.frag_no += 1; + let geometry = sanitize_skia_svg_fragment(&remap_ids(inner, &prefix)); + self.defs.push_str(&format!( + "{geometry}" + )); + } +} + +/// Draws a clip geometry into `cv` (already set up with the page transform). +fn draw_clip_geometry(cv: &skia::Canvas, shape: &Shape, tree: ShapesPoolRef, paint: &Paint) { + if let Type::Group(_) = &shape.shape_type { + for child_id in shape.children_ids_iter_forward(true) { + if let Some(child) = tree.get(child_id) { + draw_clip_geometry(cv, child, tree, paint); + } + } + return; + } + + cv.save(); + cv.concat(&shape.centered_transform()); + draw_shape_geometry(cv, shape, paint); + cv.restore(); +} + +/// Builds the `` attribute string for a shape's composite effects (opacity, +/// blend mode). Returns `None` when the shape needs no wrapper. +/// +/// Layer blur / shadows are intentionally omitted here — they need native SVG +/// filter re-emission to survive `SkSVGDevice` and land in later PRs. +pub(super) fn effect_attrs(element: &Shape) -> Option { + let mut parts: Vec = Vec::new(); + + let opacity = element.opacity(); + if opacity < 1.0 { + parts.push(format!("opacity=\"{opacity}\"")); + } + + if let Some(css) = blend_css(element.blend_mode().0) { + parts.push(format!("style=\"mix-blend-mode:{css}\"")); + } + + if parts.is_empty() { + None + } else { + Some(parts.join(" ")) + } +} + +/// Maps a Skia blend mode to its CSS `mix-blend-mode` keyword. Returns `None` +/// for `SrcOver` (normal) and modes without a CSS equivalent. +fn blend_css(mode: skia::BlendMode) -> Option<&'static str> { + use skia::BlendMode::*; + Some(match mode { + Multiply => "multiply", + Screen => "screen", + Overlay => "overlay", + Darken => "darken", + Lighten => "lighten", + ColorDodge => "color-dodge", + ColorBurn => "color-burn", + HardLight => "hard-light", + SoftLight => "soft-light", + Difference => "difference", + Exclusion => "exclusion", + Hue => "hue", + Saturation => "saturation", + Color => "color", + Luminosity => "luminosity", + _ => return None, + }) +} + +/// Returns the inner body of a Skia SVG document (everything between the +/// opening `` tag and the closing ``). +fn extract_inner_svg(doc: &str) -> &str { + let start = doc + .find("').map(|e| s + e + 1)); + let end = doc.rfind(""); + match (start, end) { + (Some(s), Some(e)) if s <= e => &doc[s..e], + _ => "", + } +} + +/// Prefixes every id defined in a fragment (and its `url(#…)` / `#…` +/// references) so ids stay unique once fragments are merged into one document. +fn remap_ids(body: &str, prefix: &str) -> String { + let needle = "id=\""; + let mut ids: Vec<&str> = Vec::new(); + let mut offset = 0; + while let Some(pos) = body[offset..].find(needle) { + let start = offset + pos + needle.len(); + let Some(end_rel) = body[start..].find('"') else { + break; + }; + let id = &body[start..start + end_rel]; + if !id.is_empty() { + ids.push(id); + } + offset = start + end_rel + 1; + } + + ids.sort_unstable(); + ids.dedup(); + // Longest-first so a shorter id can't collide inside a longer one. + ids.sort_by_key(|b| std::cmp::Reverse(b.len())); + + let mut out = body.to_string(); + for id in ids { + let new_id = format!("{prefix}{id}"); + out = out.replace(&format!("id=\"{id}\""), &format!("id=\"{new_id}\"")); + out = out.replace(&format!("url(#{id})"), &format!("url(#{new_id})")); + out = out.replace(&format!("=\"#{id}\""), &format!("=\"#{new_id}\"")); + } + out +} + +/// Skia's SVG backend appends a trailing comma to list-valued `` attrs +/// (`x`, `y`, `dx`, `dy`). Firefox rejects the malformed list and drops the +/// glyph positioning (text vanishes or mis-renders). +fn sanitize_skia_svg_fragment(body: &str) -> String { + const LIST_ATTRS: [&str; 4] = ["x=\"", "y=\"", "dx=\"", "dy=\""]; + let mut out = body.to_string(); + + for attr in LIST_ATTRS { + let mut search_from = 0; + while let Some(rel) = out[search_from..].find(attr) { + let value_start = search_from + rel + attr.len(); + let Some(end_rel) = out[value_start..].find('"') else { + break; + }; + let value_end = value_start + end_rel; + let trimmed_len = out[value_start..value_end] + .trim_end() + .trim_end_matches(',') + .len(); + if trimmed_len != value_end - value_start { + let trimmed = out[value_start..value_start + trimmed_len].to_string(); + out.replace_range(value_start..value_end, &trimmed); + search_from = value_start + trimmed_len + 1; + } else { + search_from = value_end + 1; + } + } + } + + out +} + +#[cfg(test)] +mod tests { + use super::sanitize_skia_svg_fragment; + + #[test] + fn strips_trailing_comma_from_text_position_lists() { + let input = r#"asd"#; + let out = sanitize_skia_svg_fragment(input); + assert!(out.contains(r#"x="1119, 1374.8594, 1584.332""#)); + assert!(out.contains(r#"y="402""#)); + assert!(!out.contains("1584.332, \"")); + assert!(!out.contains("402, \"")); + } +} diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs new file mode 100644 index 0000000000..46729b94b0 --- /dev/null +++ b/render-wasm/src/render/svg/fixtures.rs @@ -0,0 +1,162 @@ +//! GPU-free scene builders and render helpers for SVG export tests. + +use skia_safe as skia; + +use crate::globals::TestRenderResourcesGuard; +use crate::render::{FontStore, RenderResources}; +use crate::shapes::{ + Fill, FontFamily, FontStyle, Frame, Group, GrowType, Paragraph, Rect, SolidColor, TextAlign, + TextContent, TextDirection, TextSpan, Type, +}; +use crate::state::ShapesPool; +use crate::utils::uuid_from_u32_quartet; +use crate::uuid::Uuid; + +use super::render_tree_to_svg; + +/// Font URL referenced in exported SVG `@font-face` rules. +pub(super) const TEST_FONT_URL: &str = "fonts/sourcesanspro-regular.ttf"; + +fn register_test_font_urls(fonts: &mut FontStore) { + let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal); + fonts.set_source_url(&family.alias(), TEST_FONT_URL.to_string()); +} + +/// Deterministic UUID from a small integer, keeping snapshots stable. +pub(super) fn uid(n: u32) -> Uuid { + uuid_from_u32_quartet(0, 0, 0, n) +} + +/// Adds a solid-filled rectangle to the pool. +pub(super) fn add_solid_rect( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + color: skia::Color, +) { + add_rect_with_fills( + pool, + id, + parent, + (l, t, r, b), + vec![Fill::Solid(SolidColor(color))], + ); +} + +/// Adds a rectangle with the given fill stack (bottom → top). +pub(super) fn add_rect_with_fills( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + fills: Vec, +) { + let shape = pool.add_shape(id); + shape.set_parent(parent); + shape.set_shape_type(Type::Rect(Rect::default())); + shape.set_selrect(l, t, r, b); + shape.set_fills(fills); +} + +/// Adds a solid-filled frame (board) to the pool. +pub(super) fn add_frame( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + color: skia::Color, + clip: bool, +) { + let shape = pool.add_shape(id); + shape.set_parent(parent); + shape.set_shape_type(Type::Frame(Frame::default())); + shape.set_selrect(l, t, r, b); + shape.set_fills(vec![Fill::Solid(SolidColor(color))]); + shape.set_clip(clip); +} + +/// Adds an empty (unmasked) group. +pub(super) fn add_group( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + children: &[Uuid], +) { + let shape = pool.add_shape(id); + shape.set_parent(parent); + shape.set_shape_type(Type::Group(Group { masked: false })); + shape.set_selrect(l, t, r, b); + for child in children { + shape.add_child(*child); + } +} + +/// Adds a single-line text shape using the embedded default font. +pub(super) fn add_solid_text( + pool: &mut ShapesPool, + id: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + text: &str, + font_size: f32, + fill: skia::Color, +) { + add_text_with_fills( + pool, + id, + (l, t, r, b), + text, + font_size, + vec![Fill::Solid(SolidColor(fill))], + ); +} + +/// Adds a single-line text shape with the given fill stack (top → bottom). +pub(super) fn add_text_with_fills( + pool: &mut ShapesPool, + id: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + text: &str, + font_size: f32, + fills: Vec, +) { + let bounds = skia::Rect::from_ltrb(l, t, r, b); + let mut content = TextContent::new(bounds, GrowType::Fixed); + let line_height = 1.2; + let span = TextSpan::new( + text.to_string(), + FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), + font_size, + line_height, + 0.0, + None, + None, + TextDirection::LTR, + 400, + Uuid::nil(), + fills, + ); + content.add_paragraph(Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + line_height, + 0.0, + vec![span], + )); + + let shape = pool.add_shape(id); + shape.set_parent(Uuid::nil()); + shape.set_selrect(l, t, r, b); + shape.set_shape_type(Type::Text(content)); +} + +pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + register_test_font_urls(&mut resources.fonts); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let bytes = render_tree_to_svg(&mut resources, &root, pool, 1.0).expect("svg export"); + String::from_utf8(bytes).expect("utf8 svg") +} diff --git a/render-wasm/src/render/svg/frames.rs b/render-wasm/src/render/svg/frames.rs new file mode 100644 index 0000000000..fead20bbf4 --- /dev/null +++ b/render-wasm/src/render/svg/frames.rs @@ -0,0 +1,66 @@ +use crate::error::Result; +use crate::render::shape_renderer::ShapeRenderer; +use crate::render::vector::VectorRenderer; +use crate::shapes::{Shape, Stroke}; +use crate::state::ShapesPoolRef; + +use super::document::{effect_attrs, SvgLayerCanvas}; +use super::render_tree; +use crate::render::RenderResources; + +pub(super) fn render_frame( + builder: &mut SvgLayerCanvas, + shared: &mut RenderResources, + element: &Shape, + tree: ShapesPoolRef, + scale: f32, +) -> Result<()> { + let matrix = element.centered_transform(); + + let effects = effect_attrs(element); + if let Some(attrs) = &effects { + builder.open_group(attrs); + } + + let clipped = element.clip_content; + if clipped { + let clip_id = builder.unique("clip"); + builder.push_clip_path(&clip_id, element, tree); + builder.open_group(&format!("clip-path=\"url(#{clip_id})\"")); + } + + // Frame background (frame space). + if !element.fills.is_empty() { + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale, false); + renderer.draw_fills(element, &element.fills)?; + canvas.restore(); + } + + // Children (absolute coords). + let children: Vec<_> = element.children_ids_iter_forward(false).copied().collect(); + for child_id in &children { + render_tree(builder, shared, child_id, tree, scale)?; + } + + // Strokes over children (frame space). + let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect(); + if !visible_strokes.is_empty() { + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale, false); + renderer.draw_strokes(element, &visible_strokes)?; + canvas.restore(); + } + + if clipped { + builder.close_group(); + } + if effects.is_some() { + builder.close_group(); + } + Ok(()) +} diff --git a/render-wasm/src/render/svg/groups.rs b/render-wasm/src/render/svg/groups.rs new file mode 100644 index 0000000000..5bca7d8876 --- /dev/null +++ b/render-wasm/src/render/svg/groups.rs @@ -0,0 +1,35 @@ +use crate::error::Result; +use crate::shapes::Shape; +use crate::state::ShapesPoolRef; + +use super::document::{effect_attrs, SvgLayerCanvas}; +use super::render_tree; +use crate::render::RenderResources; + +pub(super) fn render_group( + builder: &mut SvgLayerCanvas, + shared: &mut RenderResources, + element: &Shape, + tree: ShapesPoolRef, + scale: f32, +) -> Result<()> { + let effects = effect_attrs(element); + if let Some(attrs) = &effects { + builder.open_group(attrs); + } + + // Masked groups are deferred: they need an alpha `` compositor that + // will land in a later PR. For now we still emit the full child list + // (including the mask shape as normal content) so basic group opacity + // keeps working. + + let children: Vec<_> = element.children_ids_iter_forward(false).copied().collect(); + for child_id in &children { + render_tree(builder, shared, child_id, tree, scale)?; + } + + if effects.is_some() { + builder.close_group(); + } + Ok(()) +} diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs new file mode 100644 index 0000000000..e992b1ad66 --- /dev/null +++ b/render-wasm/src/render/svg/mod.rs @@ -0,0 +1,224 @@ +use skia_safe::{self as skia}; + +use std::collections::HashSet; + +use crate::error::Result; +use crate::math::Bounds; +use crate::shapes::{Shape, Type}; +use crate::state::ShapesPoolRef; +use crate::uuid::Uuid; + +use super::vector::{render_leaf_content, VectorRenderer}; +use super::RenderResources; + +/// Collects the registered font aliases used by every text span in the subtree +/// rooted at `id`, so the exporter can emit `@font-face` rules for them. +fn collect_font_aliases(tree: ShapesPoolRef, id: &Uuid, out: &mut HashSet) { + let Some(shape) = tree.get(id) else { + return; + }; + + if let Type::Text(_) = &shape.shape_type { + for paragraph in shape.get_text_content().paragraphs() { + for span in paragraph.children() { + out.insert(format!("{}", span.font_family)); + } + } + } + + for child_id in shape.children_ids_iter_forward(true) { + collect_font_aliases(tree, child_id, out); + } +} + +/// Bounds for the root `` width, height, and viewBox. +/// +/// Text uses [`Shape::layer_bounds`]: glyph metrics, at least the `selrect`, and +/// room for strokes/shadows/blur. Other shapes use `extrect` for overflow. +fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect { + if matches!(shape.shape_type, Type::Text(_)) { + let mut bounds = Bounds::from_rect(&shape.layer_bounds()); + if !shape.transform.is_identity() { + let mut matrix = shape.transform; + let center = shape.center(); + matrix.post_translate(center); + matrix.pre_translate(-center); + bounds.transform_mut(&matrix); + } + bounds.to_rect() + } else { + shape.extrect(tree, scale) + } +} + +/// Renders a shape tree to an SVG document and returns the raw SVG bytes. +/// +/// Dedicated vector-SVG render path. Leaf content (paths, fills, …) is emitted +/// as real SVG markup via short-lived Skia SVG canvases, while composite +/// effects that `SkSVGDevice` would drop (`save_layer` opacity / blend) are +/// composed as native SVG `` wrappers. Frame `clip content` uses a native +/// ``. +/// +/// Special-case re-emission for shadows, layer blur, masks, text strokes, and +/// deferred strokes is intentionally out of scope for this cut. +pub fn render_to_svg( + shared: &mut RenderResources, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, +) -> Result> { + render_tree_to_svg(shared, id, tree, scale) +} + +/// Core SVG export, kept as a separate entry so headless native tests can call +/// it with a GPU-free [`RenderResources`]. +pub(crate) fn render_tree_to_svg( + shared: &mut RenderResources, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, +) -> Result> { + let shape = tree + .get(id) + .ok_or_else(|| crate::error::Error::CriticalError("Shape not found for SVG".to_string()))?; + // Use the extended rect so unclipped frames grow to include overflowing + // children (and leaf effects like shadows when those land). Clipped frames + // still resolve to roughly their selrect because `extrect` skips joining + // children when `clip_content` is on. + let bounds = svg_page_bounds(shape, tree, scale); + + let page_w = bounds.width() * scale; + let page_h = bounds.height() * scale; + let rect = skia::Rect::from_xywh(0., 0., page_w, page_h); + + let (defs, body) = render_body(shared, id, tree, scale, rect, -bounds.left(), -bounds.top())?; + + let mut aliases = HashSet::new(); + collect_font_aliases(tree, id, &mut aliases); + let font_css = shared.fonts.font_face_css_for_aliases(&aliases); + + let mut out = String::with_capacity(body.len() + defs.len() + font_css.len() + 256); + out.push_str("\n"); + out.push_str(&format!( + "" + )); + + if !font_css.is_empty() || !defs.is_empty() { + out.push_str(""); + if !font_css.is_empty() { + out.push_str(&format!( + "" + )); + } + out.push_str(&defs); + out.push_str(""); + } + + out.push_str(&body); + out.push_str(""); + + Ok(out.into_bytes()) +} + +mod document; +mod frames; +mod groups; +mod text; + +use document::SvgLayerCanvas; +use frames::render_frame; +use groups::render_group; +use text::render_text_fill; + +use document::effect_attrs; + +/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`. +fn render_body( + shared: &mut RenderResources, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, + page_rect: skia::Rect, + tx: f32, + ty: f32, +) -> Result<(String, String)> { + let mut builder = SvgLayerCanvas::new(scale, page_rect, tx, ty); + render_tree(&mut builder, shared, id, tree, scale)?; + builder.flush(); + Ok((builder.defs, builder.out)) +} + +fn render_tree( + builder: &mut SvgLayerCanvas, + shared: &mut RenderResources, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, +) -> Result<()> { + let Some(element) = tree.get(id) else { + return Ok(()); + }; + if element.hidden { + return Ok(()); + } + + match &element.shape_type { + Type::Group(_) => render_group(builder, shared, element, tree, scale), + Type::Frame(_) => render_frame(builder, shared, element, tree, scale), + Type::Rect(_) + | Type::Circle + | Type::Path(_) + | Type::Bool(_) + | Type::Text(_) + | Type::SVGRaw(_) => render_leaf(builder, shared, element, scale), + } +} + +fn render_leaf( + builder: &mut SvgLayerCanvas, + shared: &mut RenderResources, + element: &Shape, + scale: f32, +) -> Result<()> { + let effects = effect_attrs(element); + if let Some(attrs) = &effects { + builder.open_group(attrs); + } + + { + if matches!(element.shape_type, Type::Text(_)) { + render_text_fill(builder, element)?; + } else { + let matrix = element.centered_transform(); + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale, false); + render_leaf_content(&mut renderer, element)?; + canvas.restore(); + } + } + + if effects.is_some() { + builder.close_group(); + } + Ok(()) +} + +// =========================================================================== +// Tests +// =========================================================================== +// +// Fast, headless native tests (`cargo test --bin render_wasm`) for the SVG +// exporter. They bypass the GPU/browser stack: shapes are built into a +// `ShapesPool` and rendered through [`render_tree_to_svg`] with a headless +// [`RenderResources`]. Output is checked with `insta` snapshots. +// +// To (re)generate snapshots after a deliberate change: +// cargo insta test --accept --bin render_wasm +#[cfg(test)] +mod fixtures; + +#[cfg(test)] +mod tests; diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_clipped_frame_with_overflowing_child.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_clipped_frame_with_overflowing_child.snap new file mode 100644 index 0000000000..547b8a2479 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_clipped_frame_with_overflowing_child.snap @@ -0,0 +1,11 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_group_with_two_rects_and_group_opacity.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_group_with_two_rects_and_group_opacity.snap new file mode 100644 index 0000000000..86953ae155 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_group_with_two_rects_and_group_opacity.snap @@ -0,0 +1,9 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_rect_with_multiple_solid_fills.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_rect_with_multiple_solid_fills.snap new file mode 100644 index 0000000000..e8f99d11a0 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_rect_with_multiple_solid_fills.snap @@ -0,0 +1,9 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_solid_rect.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_solid_rect.snap new file mode 100644 index 0000000000..2eebb4cfe1 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_solid_rect.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: "render(&pool, id)" +--- + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_an_unclipped_frame_with_overflowing_child.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_an_unclipped_frame_with_overflowing_child.snap new file mode 100644 index 0000000000..3a52a3acdb --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_an_unclipped_frame_with_overflowing_child.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/tests.rs +assertion_line: 175 +expression: svg +--- + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_opacity_and_blend_mode_as_group_wrappers.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_opacity_and_blend_mode_as_group_wrappers.snap new file mode 100644 index 0000000000..27f46df7e4 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_opacity_and_blend_mode_as_group_wrappers.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_solid_text_with_font_face.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_solid_text_with_font_face.snap new file mode 100644 index 0000000000..d1f4dd9b15 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_solid_text_with_font_face.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + HOLA + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_multiple_solid_fills.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_multiple_solid_fills.snap new file mode 100644 index 0000000000..6a2f690ee4 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_multiple_solid_fills.snap @@ -0,0 +1,13 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + HOLA + + + HOLA + + diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs new file mode 100644 index 0000000000..5ed0fc81c3 --- /dev/null +++ b/render-wasm/src/render/svg/tests.rs @@ -0,0 +1,268 @@ +use super::fixtures::*; + +use crate::shapes::{BlendMode, Fill, SolidColor}; +use crate::state::ShapesPool; +use crate::uuid::Uuid; + +use skia_safe as skia; + +#[test] +fn exports_a_rect_with_multiple_solid_fills() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_rect_with_fills( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + vec![ + // fills[0] is topmost in Penpot (red 50%). + Fill::Solid(SolidColor(skia::Color::from_argb(128, 245, 0, 0))), + // fills[1] is underneath (blue 100%). + Fill::Solid(SolidColor(skia::Color::from_rgb(0, 63, 255))), + ], + ); + + let svg = render(&pool, id); + assert!( + svg.matches("fill=\"#").count() >= 2, + "each solid fill must emit a fill attribute: {svg}" + ); + let blue_pos = svg.to_ascii_lowercase().find("fill=\"#003fff\""); + let red_pos = svg.to_ascii_lowercase().find("fill=\"#f50000\""); + assert!(blue_pos.is_some(), "missing bottom blue fill: {svg}"); + assert!(red_pos.is_some(), "missing top red fill: {svg}"); + assert!( + blue_pos.unwrap() < red_pos.unwrap(), + "bottom fill must appear before top fill in SVG: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_a_solid_rect() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(255, 0, 0), + ); + + insta::assert_snapshot!(render(&pool, id)); +} + +#[test] +fn exports_leaf_opacity_and_blend_mode_as_group_wrappers() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 100.0), + skia::Color::from_rgb(0, 128, 255), + ); + { + let shape = pool.get_mut(&id).unwrap(); + shape.set_opacity(0.5); + shape.set_blend_mode(BlendMode(skia::BlendMode::Multiply)); + } + + let svg = render(&pool, id); + assert!( + svg.contains("opacity=\"0.5\""), + "missing opacity wrapper: {svg}" + ); + assert!( + svg.contains("mix-blend-mode:multiply"), + "missing blend-mode wrapper: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_a_group_with_two_rects_and_group_opacity() { + let mut pool = ShapesPool::new(); + let group_id = uid(1); + let a = uid(2); + let b = uid(3); + + add_group( + &mut pool, + group_id, + Uuid::nil(), + (0.0, 0.0, 200.0, 100.0), + &[a, b], + ); + { + let group = pool.get_mut(&group_id).unwrap(); + group.set_opacity(0.7); + } + + add_solid_rect( + &mut pool, + a, + group_id, + (0.0, 0.0, 90.0, 100.0), + skia::Color::from_rgb(0, 0, 255), + ); + add_solid_rect( + &mut pool, + b, + group_id, + (110.0, 0.0, 200.0, 100.0), + skia::Color::from_rgb(0, 200, 0), + ); + + let svg = render(&pool, group_id); + assert!( + svg.contains("opacity=\"0.7\""), + "missing group opacity wrapper: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_a_clipped_frame_with_overflowing_child() { + let mut pool = ShapesPool::new(); + let frame_id = uid(1); + let child = uid(2); + + add_frame( + &mut pool, + frame_id, + Uuid::nil(), + (0.0, 0.0, 100.0, 100.0), + skia::Color::from_rgb(240, 240, 240), + true, + ); + { + let frame = pool.get_mut(&frame_id).unwrap(); + frame.add_child(child); + } + + // Child extends past the frame's right/bottom edge. + add_solid_rect( + &mut pool, + child, + frame_id, + (50.0, 50.0, 150.0, 150.0), + skia::Color::from_rgb(255, 0, 0), + ); + + let svg = render(&pool, frame_id); + assert!( + svg.contains("clip-path=\"url(#"), + "missing frame clip-path wrapper: {svg}" + ); + assert!(svg.contains("= 2, + "each solid fill must emit a fill attribute: {svg}" + ); + let blue_pos = svg.to_ascii_lowercase().find("fill=\"#003fff\""); + let red_pos = svg.to_ascii_lowercase().find("fill=\"#f50000\""); + assert!(blue_pos.is_some(), "missing bottom blue fill: {svg}"); + assert!(red_pos.is_some(), "missing top red fill: {svg}"); + assert!( + blue_pos.unwrap() < red_pos.unwrap(), + "bottom fill must appear before top fill in SVG: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_solid_text_with_font_face() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_text( + &mut pool, + id, + (0.0, 0.0, 560.0, 240.0), + "HOLA", + 200.0, + skia::Color::from_rgb(0xE1, 0x7F, 0xDA), + ); + + let svg = render(&pool, id); + assert!(svg.contains("` elements. +/// +/// The shared GPU/PDF renderer wraps text in `save_layer`, which `SkSVGDevice` +/// silently drops. Text strokes are handled separately in a later PR. +pub(super) fn render_text_fill(builder: &mut SvgLayerCanvas, element: &Shape) -> Result<()> { + let matrix = element.centered_transform(); + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + text::paint_text_fill(canvas, element); + canvas.restore(); + Ok(()) +} diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index c176084688..ef41565af7 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -3,8 +3,8 @@ use crate::{ error::Result, math::Rect, shapes::{ - calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, ParagraphLayout, Stroke, - StrokeKind, TextContent, + add_text_with_tabs, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, + ParagraphLayout, Stroke, StrokeKind, TextContent, VerticalAlign, }, utils::{get_fallback_fonts, get_font_collection}, }; @@ -55,7 +55,7 @@ pub fn stroke_paragraph_builder_group_from_text( paragraph.line_height(), ); builder.push_style(&stroke_style); - builder.add_text(&text); + add_text_with_tabs(builder, &text, span.font_size); } } @@ -318,6 +318,141 @@ pub fn render_overlay_emoji( ) } +/// Paint fill glyphs from `TextContent.layout` when the cache is valid. +/// +/// Avoids rebuilding ParagraphBuilders and re-running Skia layout on every +/// paint. Only safe for the plain fill pass (no stroke/shadow-specific builders). +/// Returns `true` when painting was done from cache. +pub fn try_paint_from_layout_cache( + render_state: Option<&mut RenderState>, + canvas: Option<&Canvas>, + shape: &Shape, + surface_id: Option, + layout_cache_rotation_only: bool, +) -> Result { + let text_content = shape.get_text_content(); + let cache_usable = if layout_cache_rotation_only { + text_content.layout_cache_versions_match() + } else { + text_content.has_usable_paint_layout(shape) + }; + if !cache_usable { + return Ok(false); + } + + if let Some(render_state) = render_state { + let target_surface = surface_id.unwrap_or(SurfaceId::Fills); + let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface); + paint_from_cached_layout(canvas, shape, text_content); + return Ok(true); + } + + if let Some(canvas) = canvas { + paint_from_cached_layout(canvas, shape, text_content); + return Ok(true); + } + + Ok(false) +} + +fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextContent) { + let selrect = shape.selrect(); + let x = selrect.x(); + let base_y = selrect.y(); + let paragraphs = &text_content.layout.paragraphs; + let draw_decorations = text_content.has_text_decorations(); + + let total_text_height: f32 = paragraphs + .iter() + .filter_map(|group| group.first()) + .map(|p| p.height()) + .sum(); + let vertical_offset = match shape.vertical_align() { + VerticalAlign::Center => (selrect.height() - total_text_height) / 2.0, + VerticalAlign::Bottom => selrect.height() - total_text_height, + _ => 0.0, + }; + + let mut y_accum = base_y + vertical_offset; + for group in paragraphs.iter() { + let Some(paragraph) = group.first() else { + continue; + }; + paragraph.paint(canvas, (x, y_accum)); + if draw_decorations { + paint_decorations_for_paragraph(canvas, paragraph, x, y_accum); + } + y_accum += paragraph.height(); + } +} + +fn paint_decorations_for_paragraph( + canvas: &Canvas, + paragraph: &skia::textlayout::Paragraph, + x: f32, + y_accum: f32, +) { + let line_metrics = paragraph.get_line_metrics(); + for line in &line_metrics { + let style_metrics: Vec<_> = line + .get_style_metrics(line.start_index..line.end_index) + .into_iter() + .collect(); + let line_baseline = y_accum + line.baseline as f32; + let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) = + calculate_decoration_metrics(&style_metrics, line_baseline); + for (i, (style_start, style_metric)) in style_metrics.iter().enumerate() { + let text_style = &style_metric.text_style; + let style_end = style_metrics + .get(i + 1) + .map(|(next_i, _)| *next_i) + .unwrap_or(line.end_index); + let seg_start = (*style_start).max(line.start_index); + let seg_end = style_end.min(line.end_index); + if seg_start >= seg_end { + continue; + } + let rects = paragraph.get_rects_for_range( + seg_start..seg_end, + skia::textlayout::RectHeightStyle::Tight, + skia::textlayout::RectWidthStyle::Tight, + ); + let (segment_width, actual_x_offset) = if !rects.is_empty() { + let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum(); + let skia_x_offset = rects + .first() + .map(|r| r.rect.left - line.left as f32) + .unwrap_or(0.0); + (total_width, skia_x_offset) + } else { + (0.0, 0.0) + }; + let text_left = x + line.left as f32 + actual_x_offset; + let text_width = segment_width; + if text_style.decoration().ty == TextDecoration::UNDERLINE { + draw_text_decorations( + canvas, + text_style, + Some(underline_y.unwrap_or(line_baseline)), + max_underline_thickness, + text_left, + text_width, + ); + } + if text_style.decoration().ty == TextDecoration::LINE_THROUGH { + draw_text_decorations( + canvas, + text_style, + Some(strike_y.unwrap_or(line_baseline)), + max_strike_thickness, + text_left, + text_width, + ); + } + } + } +} + #[allow(clippy::too_many_arguments)] fn render_text_on_canvas( canvas: &Canvas, @@ -329,15 +464,23 @@ fn render_text_on_canvas( layer_opacity: Option, overlay_emoji: bool, ) { + let layer_bounds = shape.layer_bounds(); + + // Layer stack is managed here (blur / shadow / inset). `draw_text` is + // self-contained and only opens a layer when stroke-group opacity needs it. if let Some(blur_filter) = blur { let mut blur_paint = Paint::default(); blur_paint.set_image_filter(blur_filter.clone()); - let blur_layer = SaveLayerRec::default().paint(&blur_paint); + let blur_layer = SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&blur_paint); canvas.save_layer(&blur_layer); } if let Some(shadow_paint) = shadow { - let layer_rec = SaveLayerRec::default().paint(shadow_paint); + let layer_rec = SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(shadow_paint); canvas.save_layer(&layer_rec); draw_text( canvas, @@ -351,7 +494,9 @@ fn render_text_on_canvas( if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) { let mut layer_paint = Paint::default(); layer_paint.set_image_filter(erode); - let layer_rec = SaveLayerRec::default().paint(&layer_paint); + let layer_rec = SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&layer_paint); canvas.save_layer(&layer_rec); draw_text( canvas, @@ -383,8 +528,24 @@ fn render_text_on_canvas( if blur.is_some() { canvas.restore(); } +} - canvas.restore(); +/// Paints text fill for vector SVG export. Skips `save_layer` wrappers that +/// `SkSVGDevice` would drop. +pub fn paint_text_fill(canvas: &Canvas, shape: &Shape) { + let text_content = shape.get_text_content(); + let text_content = text_content.new_bounds(shape.selrect()); + let max_layers = text_content.max_fill_layers(); + if max_layers == 0 { + return; + } + + // Each fill layer is painted separately so SkSVGDevice can emit `fill` + // attributes (merged shaders are dropped). Bottom layer first. + for layer in 0..max_layers { + let mut paragraph_builders = text_content.paragraph_builder_group_for_fill_layer(layer); + paint_text_with_emoji_overlay(canvas, shape, &mut paragraph_builders, false); + } } /// Lays out and paints paragraph builders without any layer management. @@ -582,7 +743,10 @@ fn draw_decoration_stroke( skia::BlendMode::SrcOut }; - canvas.save_layer(&SaveLayerRec::default()); + let outset = stroke_paint.stroke_width().max(0.0); + let layer_bounds = bar.with_outset((outset, outset)); + + canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds)); let mut mask_paint = Paint::default(); mask_paint.set_color(skia::Color::BLACK); mask_paint.set_anti_alias(true); @@ -590,7 +754,11 @@ fn draw_decoration_stroke( let mut blend_paint = Paint::default(); blend_paint.set_blend_mode(blend); - canvas.save_layer(&SaveLayerRec::default().paint(&blend_paint)); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&blend_paint), + ); canvas.draw_rect(bar, stroke_paint); canvas.restore(); canvas.restore(); @@ -705,7 +873,12 @@ pub fn render_emoji_overlay( if let Some(blur_filter) = blur { let mut blur_paint = Paint::default(); blur_paint.set_image_filter(blur_filter.clone()); - canvas.save_layer(&SaveLayerRec::default().paint(&blur_paint)); + let layer_bounds = shape.layer_bounds(); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&blur_paint), + ); } for (emoji_para, deco_para) in emoji_layout @@ -728,16 +901,22 @@ fn draw_text( layer_opacity: Option, overlay_emoji: bool, ) { + // Multi-style spans are already encoded in each ParagraphBuilder's + // TextStyles; paragraph.paint handles them without an isolation layer. + // Only open a save_layer when stroke-group opacity must composite as one. if let Some(opacity) = layer_opacity { + let layer_bounds = shape.layer_bounds(); let mut opacity_paint = Paint::default(); opacity_paint.set_alpha_f(opacity); - let layer_rec = SaveLayerRec::default().paint(&opacity_paint); + let layer_rec = SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&opacity_paint); canvas.save_layer(&layer_rec); + paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji); + canvas.restore(); } else { - canvas.save_layer(&SaveLayerRec::default()); + paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji); } - - paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji); } /// Renders a text stroke masked to the glyph shape. @@ -759,27 +938,41 @@ fn render_masked_stroke_on_canvas( blur: Option<&ImageFilter>, layer_opacity: Option, ) { + let layer_bounds = shape.layer_bounds(); + if let Some(blur_filter) = blur { let mut blur_paint = Paint::default(); blur_paint.set_image_filter(blur_filter.clone()); - canvas.save_layer(&SaveLayerRec::default().paint(&blur_paint)); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&blur_paint), + ); } if let Some(opacity) = layer_opacity { let mut opacity_paint = Paint::default(); opacity_paint.set_alpha_f(opacity); - canvas.save_layer(&SaveLayerRec::default().paint(&opacity_paint)); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&opacity_paint), + ); } - canvas.save_layer(&SaveLayerRec::default()); + canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds)); - canvas.save_layer(&SaveLayerRec::default()); + canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds)); paint_text(canvas, shape, mask_builders); let mut stroke_paint = Paint::default(); stroke_paint.set_blend_mode(stroke_mask_blend); - canvas.save_layer(&SaveLayerRec::default().paint(&stroke_paint)); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&stroke_paint), + ); paint_text(canvas, shape, stroke_builders); @@ -789,7 +982,11 @@ fn render_masked_stroke_on_canvas( if let Some(fill_builders) = fill_builders { let mut dst_over_paint = Paint::default(); dst_over_paint.set_blend_mode(skia::BlendMode::DstOver); - canvas.save_layer(&SaveLayerRec::default().paint(&dst_over_paint)); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&dst_over_paint), + ); paint_text(canvas, shape, fill_builders); diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 8ed53bb436..8154981e99 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -156,12 +156,13 @@ fn calculate_cursor_rect( .map(|span| span.text.chars().count()) .sum(); + // Skia ranges are UTF-16 code units, not characters. let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 { // Empty paragraph - use default height (0.0, 0.0, 1.0, laid_out_para.height()) } else if char_pos == 0 { let rects = laid_out_para.get_rects_for_range( - 0..1, + 0..para.char_utf16_len_at(0), RectHeightStyle::Max, RectWidthStyle::Tight, ); @@ -172,25 +173,30 @@ fn calculate_cursor_rect( (0.0, 0.0, 1.0, laid_out_para.height()) } } else if char_pos >= para_char_count { + let last_char = para_char_count.saturating_sub(1); + let last_start = para.char_offset_to_utf16(last_char); let rects = laid_out_para.get_rects_for_range( - para_char_count.saturating_sub(1)..para_char_count, + last_start..last_start + para.char_utf16_len_at(last_char), RectHeightStyle::Max, RectWidthStyle::Tight, ); if !rects.is_empty() { let r = &rects[0].rect; (r.right(), r.top(), r.width(), r.height()) - } else { + } else if let Some(line) = laid_out_para.get_line_metrics().last() { ( - laid_out_para.longest_line(), + line.left as f32 + line.width as f32, 0.0, 1.0, laid_out_para.height(), ) + } else { + (0.0, 0.0, 1.0, laid_out_para.height()) } } else { + let utf16_pos = para.char_offset_to_utf16(char_pos); let rects = laid_out_para.get_rects_for_range( - char_pos..char_pos + 1, + utf16_pos..utf16_pos + para.char_utf16_len_at(char_pos), RectHeightStyle::Max, RectWidthStyle::Tight, ); @@ -264,7 +270,7 @@ fn calculate_selection_rects( if range_start < range_end { use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; let text_boxes = laid_out_para.get_rects_for_range( - range_start..range_end, + para.char_offset_to_utf16(range_start)..para.char_offset_to_utf16(range_end), RectHeightStyle::Max, RectWidthStyle::Tight, ); diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index 23b797023e..b19117b9d4 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -22,14 +22,24 @@ pub(super) struct VectorRenderer<'a> { canvas: &'a Canvas, shared: &'a mut RenderResources, scale: f32, + /// When `true`, multiple fills are composited into a single shader (PDF). + /// When `false`, each fill is drawn separately so SkSVGDevice can emit + /// `fill` attributes (SVG export). + compose_fills: bool, } impl<'a> VectorRenderer<'a> { - pub fn new(canvas: &'a Canvas, shared: &'a mut RenderResources, scale: f32) -> Self { + pub fn new( + canvas: &'a Canvas, + shared: &'a mut RenderResources, + scale: f32, + compose_fills: bool, + ) -> Self { Self { canvas, shared, scale, + compose_fills, } } } @@ -40,9 +50,9 @@ impl ShapeRenderer for VectorRenderer<'_> { return Ok(()); } - // Handle image fills individually let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_))); - if has_image_fills { + if !self.compose_fills || has_image_fills { + // fills[0] is the topmost layer; draw bottom → top (matches GPU + classic SVG). for fill in fills.iter().rev() { match fill { Fill::Image(image_fill) => { @@ -79,11 +89,14 @@ impl ShapeRenderer for VectorRenderer<'_> { } fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()> { + let layer_bounds = shape.layer_bounds(); for shadow in shape.drop_shadows_visible() { if let Some(filter) = shadow.get_drop_shadow_filter() { let mut paint = Paint::default(); paint.set_image_filter(filter); - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + let layer_rec = skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&paint); self.canvas.save_layer(&layer_rec); let mut fill_paint = Paint::default(); fill_paint.set_anti_alias(true); @@ -99,10 +112,14 @@ impl ShapeRenderer for VectorRenderer<'_> { if !shape.has_fills() { return Ok(()); } + let layer_bounds = shape.layer_bounds(); for shadow in shape.inner_shadows_visible() { let paint = shadow.get_inner_shadow_paint(true, shape.image_filter(1.).as_ref()); - self.canvas - .save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint)); + self.canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&paint), + ); let mut fill_paint = Paint::default(); fill_paint.set_anti_alias(true); fill_paint.set_color(skia::Color::BLACK); @@ -161,9 +178,13 @@ impl ShapeRenderer for VectorRenderer<'_> { }) .collect(); + let layer_bounds = shape.layer_bounds(); for shadow_paint in &drop_shadows { - self.canvas - .save_layer(&skia::canvas::SaveLayerRec::default().paint(shadow_paint)); + self.canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(shadow_paint), + ); text::render_overlay_emoji( self.canvas, @@ -331,7 +352,10 @@ impl ShapeRenderer for VectorRenderer<'_> { if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) { let mut paint = Paint::default(); paint.set_image_filter(filter); - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + let layer_bounds = shape.layer_bounds(); + let layer_rec = skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&paint); self.canvas.save_layer(&layer_rec); true } else { @@ -715,7 +739,10 @@ fn render_group( } } - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + let layer_bounds = element.extrect(tree, scale); + let layer_rec = skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&paint); canvas.save_layer(&layer_rec); } @@ -726,7 +753,12 @@ fn render_group( // as content, then re-draw the mask silhouette (the group's first child) // with DstIn to clip everything to it. let paint = Paint::default(); - canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint)); + let subtree_bounds = element.extrect(tree, scale); + canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&subtree_bounds) + .paint(&paint), + ); for child_id in &children { render_tree_inner(shared, canvas, child_id, tree, scale, opts)?; @@ -735,7 +767,11 @@ fn render_group( if let Some(mask_id) = element.mask_id() { let mut mask_paint = Paint::default(); mask_paint.set_blend_mode(skia::BlendMode::DstIn); - canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&mask_paint)); + canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&subtree_bounds) + .paint(&mask_paint), + ); render_tree_inner(shared, canvas, mask_id, tree, scale, opts)?; canvas.restore(); // mask layer } @@ -797,7 +833,10 @@ fn render_frame( } } - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + let layer_bounds = element.extrect(tree, scale); + let layer_rec = skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&paint); canvas.save_layer(&layer_rec); } @@ -816,7 +855,7 @@ fn render_frame( if !element.fills.is_empty() { canvas.save(); canvas.concat(&matrix); - let mut renderer = VectorRenderer::new(canvas, shared, scale); + let mut renderer = VectorRenderer::new(canvas, shared, scale, true); renderer.draw_fills(element, &element.fills)?; renderer.draw_fill_inner_shadows(element)?; canvas.restore(); @@ -833,7 +872,7 @@ fn render_frame( if !visible_strokes.is_empty() { canvas.save(); canvas.concat(&matrix); - let mut renderer = VectorRenderer::new(canvas, shared, scale); + let mut renderer = VectorRenderer::new(canvas, shared, scale, true); renderer.draw_strokes(element, &visible_strokes)?; canvas.restore(); } @@ -857,16 +896,21 @@ fn render_container_drop_shadows( draw_fills: bool, opts: &TreeOpts, ) -> Result<()> { + let subtree_bounds = element.extrect(tree, scale); for shadow in element.drop_shadows_visible() { let Some(filter) = shadow.get_drop_shadow_filter() else { continue; }; let mut paint = Paint::default(); paint.set_image_filter(filter); - canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint)); + canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&subtree_bounds) + .paint(&paint), + ); if draw_fills && !element.fills.is_empty() { - let mut renderer = VectorRenderer::new(canvas, shared, scale); + let mut renderer = VectorRenderer::new(canvas, shared, scale, true); renderer.draw_fills(element, &element.fills)?; } @@ -902,11 +946,14 @@ fn render_leaf( let mut paint = Paint::default(); paint.set_blend_mode(element.blend_mode().into()); paint.set_alpha_f(element.opacity()); - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + let layer_bounds = element.layer_bounds(); + let layer_rec = skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&paint); canvas.save_layer(&layer_rec); } - let mut renderer = VectorRenderer::new(canvas, shared, scale); + let mut renderer = VectorRenderer::new(canvas, shared, scale, true); // Layer blur (non-text shapes) let blur_layer = if !matches!(element.shape_type, Type::Text(_)) { @@ -933,7 +980,10 @@ fn render_leaf( /// Single source of truth for leaf content draw order/gating (fills, inner /// shadows, strokes), generic over [`ShapeRenderer`]. Drop shadows and layer /// blur are excluded — they wrap the content and are sequenced per backend. -fn render_leaf_content(renderer: &mut R, shape: &Shape) -> Result<()> { +pub(super) fn render_leaf_content( + renderer: &mut R, + shape: &Shape, +) -> Result<()> { match &shape.shape_type { Type::Text(_) => renderer.draw_text(shape)?, Type::SVGRaw(_) => renderer.draw_svg(shape)?, @@ -1101,7 +1151,8 @@ fn draw_stroke_kind_aware(canvas: &Canvas, shape: &Shape, stroke: &Stroke, paint } StrokeKind::Outer => { canvas.save(); - canvas.save_layer(&skia::canvas::SaveLayerRec::default()); + let layer_bounds = shape.layer_bounds(); + canvas.save_layer(&skia::canvas::SaveLayerRec::default().bounds(&layer_bounds)); draw_shape_geometry(canvas, shape, paint); let mut clear_paint = Paint::default(); clear_paint.set_blend_mode(skia::BlendMode::Clear); @@ -1134,7 +1185,8 @@ fn draw_image_stroke( let container = shape.selrect; canvas.save(); - canvas.save_layer(&skia::canvas::SaveLayerRec::default()); + let layer_bounds = shape.layer_bounds(); + canvas.save_layer(&skia::canvas::SaveLayerRec::default().bounds(&layer_bounds)); // Opaque stroke silhouette; the SrcIn image draw below fills it. draw_stroke_geometry(canvas, scale, shape, stroke, true); @@ -1173,7 +1225,7 @@ fn transformed_skia_path(shape: &Shape) -> Option { // --------------------------------------------------------------------------- /// Draws the shape's geometry (rect/rrect/oval/path) with the given paint. -fn draw_shape_geometry(canvas: &Canvas, shape: &Shape, paint: &Paint) { +pub(super) fn draw_shape_geometry(canvas: &Canvas, shape: &Shape, paint: &Paint) { match &shape.shape_type { Type::Rect(_) | Type::Frame(_) => { if let Some(corners) = shape.shape_type.corners() { diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 47269c5288..2db654e799 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -200,6 +200,10 @@ pub struct Shape { pub svg_transform: Option, pub ignore_constraints: bool, deleted: bool, + /// Fills from a cold-load batch, held until text content is uploaded and laid out. + deferred_batch_fills: Option>, + /// Strokes from a cold-load batch, applied together with deferred fills. + deferred_batch_strokes: Option>, } // Returns all ancestor shapes of this shape, traversing up the parent hierarchy @@ -302,6 +306,8 @@ impl Shape { svg_transform: None, ignore_constraints: false, deleted: false, + deferred_batch_fills: None, + deferred_batch_strokes: None, } } @@ -646,6 +652,7 @@ impl Shape { self.background_blur.filter(|blur| !blur.hidden) } + #[cfg(test)] pub fn add_child(&mut self, id: Uuid) { self.children.push(id); } @@ -665,6 +672,7 @@ impl Shape { } pub fn set_fills(&mut self, fills: Vec) { + self.deferred_batch_fills = None; self.fills = fills; } @@ -707,10 +715,31 @@ impl Shape { } pub fn clear_strokes(&mut self) { + self.deferred_batch_strokes = None; self.invalidate_extrect(); self.strokes.clear(); } + pub fn set_deferred_batch_fills(&mut self, fills: Vec) { + self.deferred_batch_fills = Some(fills); + } + + pub fn set_deferred_batch_strokes(&mut self, strokes: Vec) { + self.deferred_batch_strokes = Some(strokes); + } + + /// Apply fill/stroke records that were parsed from a batch upload but held + /// back until text content exists and has been laid out. + pub fn apply_deferred_batch_paint(&mut self) { + if let Some(fills) = self.deferred_batch_fills.take() { + self.fills = fills; + } + if let Some(strokes) = self.deferred_batch_strokes.take() { + self.strokes = strokes; + self.invalidate_extrect(); + } + } + pub fn set_path_segments(&mut self, segments: Vec) { match &mut self.shape_type { Type::Bool(Bool { bool_type, .. }) => { @@ -960,6 +989,14 @@ impl Shape { Bounds::from_rect(&rect) } + pub fn extrect_depends_on_children(&self) -> bool { + match self.shape_type { + Type::Group(Group { masked: true }) => true, + Type::Group(_) | Type::Frame(_) => !self.clip_content, + _ => false, + } + } + fn apply_children_bounds( &self, bounds: Bounds, @@ -1070,11 +1107,16 @@ impl Shape { extrect } - fn calculate_extrect_uncached(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect { + fn own_extrect_bounds(&self) -> Bounds { + self.expand_own_bounds(self.own_base_bounds()) + } + + /// The shape's own geometry bounds, before stroke/shadow/blur margins. + fn own_base_bounds(&self) -> Bounds { let shape = self; let max_stroke = Stroke::max_bounds_width(shape.strokes.iter(), shape.is_open()); - let mut bounds = match &shape.shape_type { + match &shape.shape_type { Type::Path(_) | Type::Bool(_) => { if let Some(path) = shape.get_skia_path() { let cap_margin = shape.cap_bounds_margin(); @@ -1091,25 +1133,49 @@ impl Shape { text_content.calculate_bounds(shape, false) } _ => shape.calculate_bounds(false), - }; + } + } - bounds = self.apply_stroke_bounds(bounds, max_stroke); + fn expand_own_bounds(&self, bounds: Bounds) -> Bounds { + let max_stroke = Stroke::max_bounds_width(self.strokes.iter(), self.is_open()); + let mut bounds = self.apply_stroke_bounds(bounds, max_stroke); bounds = self.apply_shadow_bounds(bounds); bounds = self.apply_blur_bounds(bounds); - bounds = self.apply_children_bounds(bounds, shapes_pool, scale); - bounds = self.apply_children_blur(bounds, shapes_pool); + bounds + } + + /// Bound for a `SaveLayerRec` wrapping this shape's own drawing, in + /// untransformed space (callers concatenate [`Self::centered_transform`] + /// first). Includes shadow/blur margins, so it is also a valid input bound + /// for a layer whose paint carries an image filter. + pub fn layer_bounds(&self) -> math::Rect { + let mut bounds = self.own_base_bounds(); + + if matches!(self.shape_type, Type::Text(_)) { + let mut rect = bounds.to_rect(); + rect.join(self.selrect); + bounds = Bounds::from_rect(&rect); + } + + self.expand_own_bounds(bounds).to_rect() + } + + fn calculate_extrect_uncached(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect { + // Own outsets (strokes, shadows, blur) are local-space, so they expand before the + // shape transform. Children extrects are already world-space: join them after it. + let mut bounds = self.own_extrect_bounds(); if !self.transform.is_identity() { - // Expand everything in the shape's local axis-aligned space first (strokes, - // shadows, blur, children). Only after that do we map the resulting bounds - // through the shape transform so rotation/skew is reflected in the final - // extrect. let mut matrix = self.transform; let center = self.center(); matrix.post_translate(center); matrix.pre_translate(-center); bounds.transform_mut(&matrix); } + + bounds = self.apply_children_bounds(bounds, shapes_pool, scale); + bounds = self.apply_children_blur(bounds, shapes_pool); + bounds.to_rect() } @@ -1504,7 +1570,6 @@ impl Shape { }; let path_transform = self.to_path_transform(); - let apply_doc_transform = path_transform.is_some(); for stroke in self.visible_strokes() { let Some(stroke_region) = stroke_to_path( @@ -1517,10 +1582,7 @@ impl Shape { ) else { continue; }; - let mut sk = stroke_region.to_skia_path(self.svg_attrs.as_ref()); - if apply_doc_transform { - sk = sk.make_transform(&self.shape_document_transform()); - } + let sk = stroke_region.to_skia_path(self.svg_attrs.as_ref()); acc = acc.op(&sk, skia::PathOp::Union).unwrap_or(acc); } @@ -1814,6 +1876,99 @@ impl Shape { .any(|s| s.render_kind(is_open) == StrokeKind::Inner) } + /// When true, the frame drop shadow can use the direct geometry path + /// (`render_direct_frame_drop_shadow`) instead of filter surfaces and + /// descendant silhouettes. + /// + /// Requires at least one fill; fill opacity/type does not matter because the fast + /// path shadows the frame geometry as a solid mask. + /// + /// The fast path draws fill geometry only. On the slow path, visible strokes also + /// contribute to the shadow silhouette, so frames with outer/center strokes can + /// look slightly narrower here. We keep them eligible anyway for performance. + pub fn uses_direct_container_drop_shadow(&self, tree: ShapesPoolRef, scale: f32) -> bool { + if !matches!(self.shape_type, Type::Frame(_)) { + return false; + } + if !self.has_fills() { + return false; + } + if self.blend_mode() != BlendMode::default() { + return false; + } + if self.blur.is_some() || self.background_blur.is_some() { + return false; + } + if self.has_frame_clip_layer_blur() { + return false; + } + + if self.clip_content { + return !self.descendants_have_drop_shadows(tree); + } + + self.descendants_contained_for_frame_shadow(tree, scale, self.selrect()) + } + + /// When true, the container's own fill shadow mask is enough and descendant + /// silhouettes can be skipped (same geometry assumption as the direct path). + pub fn container_fill_covers_shadow_descendants( + &self, + tree: ShapesPoolRef, + scale: f32, + ) -> bool { + self.has_fills() && self.descendants_contained_for_frame_shadow(tree, scale, self.selrect()) + } + + fn descendants_have_drop_shadows(&self, tree: ShapesPoolRef) -> bool { + for child_id in self.children_ids_iter(false) { + let Some(child) = tree.get(child_id) else { + continue; + }; + if child.hidden { + continue; + } + if child.drop_shadows_visible().next().is_some() { + return true; + } + if child.is_recursive() && child.descendants_have_drop_shadows(tree) { + return true; + } + } + false + } + + fn descendants_contained_for_frame_shadow( + &self, + tree: ShapesPoolRef, + scale: f32, + bounds: math::Rect, + ) -> bool { + if self.descendants_have_drop_shadows(tree) { + return false; + } + + const MARGIN: f32 = 0.5; + for child_id in self.children_ids_iter(false) { + let Some(child) = tree.get(child_id) else { + continue; + }; + if child.hidden { + continue; + } + let child_extrect = child.extrect(tree, scale); + if !rect_contains_with_margin(bounds, child_extrect, MARGIN) { + return false; + } + if child.is_recursive() + && !child.descendants_contained_for_frame_shadow(tree, scale, bounds) + { + return false; + } + } + true + } + pub fn drop_shadow_paints(&self) -> Vec { let drop_shadows: Vec<&Shadow> = self.drop_shadows_visible().collect(); @@ -1843,6 +1998,14 @@ impl Shape { } } +#[inline] +fn rect_contains_with_margin(outer: math::Rect, inner: math::Rect, margin: f32) -> bool { + inner.left >= outer.left - margin + && inner.top >= outer.top - margin + && inner.right <= outer.right + margin + && inner.bottom <= outer.bottom + margin +} + #[cfg(test)] mod tests { use super::*; @@ -1983,4 +2146,155 @@ mod tests { assert_eq!(extrect.right, 50.0); assert_eq!(extrect.bottom, 50.0); } + + fn frame_with_fill_and_child(fill: Fill, opacity: f32) -> (ShapesPool, Uuid) { + let mut pool = ShapesPool::new(); + pool.initialize(2); + + let frame_id = Uuid::new_v4(); + let child_id = Uuid::new_v4(); + + { + let frame = pool.add_shape(frame_id); + frame.set_shape_type(Type::Frame(Frame::default())); + frame.set_selrect(0.0, 0.0, 200.0, 100.0); + frame.add_fill(fill); + frame.opacity = opacity; + frame.children = vec![child_id]; + } + + { + let child = pool.add_shape(child_id); + child.set_shape_type(Type::Rect(Rect::default())); + child.set_selrect(10.0, 10.0, 180.0, 80.0); + child.set_parent(frame_id); + } + + (pool, frame_id) + } + + #[test] + fn frame_with_any_fill_uses_direct_container_drop_shadow() { + for (fill, opacity) in [ + (Fill::Solid(SolidColor(skia::Color::WHITE)), 1.0), + ( + Fill::Solid(SolidColor(skia::Color::from_argb(128, 255, 255, 255))), + 0.5, + ), + ] { + let (pool, frame_id) = frame_with_fill_and_child(fill, opacity); + let frame = pool.get(&frame_id).expect("frame"); + assert!(frame.uses_direct_container_drop_shadow(&pool, 1.0)); + } + } + + #[test] + fn clipped_frame_with_child_drop_shadow_rejects_direct_path() { + let (mut pool, frame_id) = + frame_with_fill_and_child(Fill::Solid(SolidColor(skia::Color::WHITE)), 1.0); + let child_id = pool.get(&frame_id).expect("frame").children[0]; + + { + let child = pool.get_mut(&child_id).expect("child"); + child.add_shadow(Shadow::new( + skia::Color::BLACK, + 4.0, + 0.0, + (0.0, 4.0), + ShadowStyle::Drop, + false, + )); + } + + let frame = pool.get(&frame_id).expect("frame"); + assert!(!frame.uses_direct_container_drop_shadow(&pool, 1.0)); + } + + #[test] + fn clipped_frame_ignores_outside_child_extrect_for_direct_path() { + let mut pool = ShapesPool::new(); + pool.initialize(2); + + let frame_id = Uuid::new_v4(); + let child_id = Uuid::new_v4(); + + { + let frame = pool.add_shape(frame_id); + frame.set_shape_type(Type::Frame(Frame::default())); + frame.set_selrect(0.0, 0.0, 200.0, 100.0); + frame.add_fill(Fill::Solid(SolidColor(skia::Color::WHITE))); + frame.set_clip(true); + frame.children = vec![child_id]; + } + + { + let child = pool.add_shape(child_id); + child.set_shape_type(Type::Rect(Rect::default())); + child.set_selrect(-50.0, -50.0, 250.0, 150.0); + child.set_parent(frame_id); + } + + let frame = pool.get(&frame_id).expect("frame"); + assert!(frame.uses_direct_container_drop_shadow(&pool, 1.0)); + } + + #[test] + fn overflow_frame_with_outside_child_rejects_direct_path() { + let mut pool = ShapesPool::new(); + pool.initialize(2); + + let frame_id = Uuid::new_v4(); + let child_id = Uuid::new_v4(); + + { + let frame = pool.add_shape(frame_id); + frame.set_shape_type(Type::Frame(Frame::default())); + frame.set_selrect(0.0, 0.0, 200.0, 100.0); + frame.add_fill(Fill::Solid(SolidColor(skia::Color::WHITE))); + frame.set_clip(false); + frame.children = vec![child_id]; + } + + { + let child = pool.add_shape(child_id); + child.set_shape_type(Type::Rect(Rect::default())); + child.set_selrect(-50.0, -50.0, 250.0, 150.0); + child.set_parent(frame_id); + } + + let frame = pool.get(&frame_id).expect("frame"); + assert!(!frame.uses_direct_container_drop_shadow(&pool, 1.0)); + assert!(!frame.container_fill_covers_shadow_descendants(&pool, 1.0)); + } + + #[test] + fn frame_with_contained_child_covers_shadow_descendants() { + let (pool, frame_id) = + frame_with_fill_and_child(Fill::Solid(SolidColor(skia::Color::WHITE)), 1.0); + let frame = pool.get(&frame_id).expect("frame"); + assert!(frame.container_fill_covers_shadow_descendants(&pool, 1.0)); + } + + #[test] + fn rotated_frame_with_contained_child_uses_direct_container_drop_shadow() { + let (mut pool, frame_id) = + frame_with_fill_and_child(Fill::Solid(SolidColor(skia::Color::WHITE)), 1.0); + + { + let frame = pool.get_mut(&frame_id).expect("frame"); + // 45° rotation around the shape center (100, 50). + let angle = std::f32::consts::FRAC_PI_4; + frame.set_transform( + angle.cos(), + angle.sin(), + -angle.sin(), + angle.cos(), + 0.0, + 0.0, + ); + } + + let frame = pool.get(&frame_id).expect("frame"); + assert!(frame.uses_direct_container_drop_shadow(&pool, 1.0)); + } } diff --git a/render-wasm/src/shapes/shadows.rs b/render-wasm/src/shapes/shadows.rs index 6cfa912659..6f446e3223 100644 --- a/render-wasm/src/shapes/shadows.rs +++ b/render-wasm/src/shapes/shadows.rs @@ -4,6 +4,18 @@ use super::blurs::radius_to_sigma; use super::Color; use crate::render::filters::compose_filters; +/// Soft visibility floor in device pixels for leaf shapes. Below this, a drop +/// shadow is visual noise relative to its blur cost. +pub const DROP_SHADOW_MIN_DEVICE_PX: f32 = 2.0; + +/// Recursive shapes (frames/groups) redraw children into the shadow layer; they +/// need a clearer on-screen footprint before that cost is worthwhile. +pub const DROP_SHADOW_RECURSIVE_MIN_DEVICE_PX: f32 = 4.0; + +/// Generous design-space shadow budget used with [`DROP_SHADOW_MIN_DEVICE_PX`] +/// for a hard global early-out (subpixel even for huge shadows). +pub const DROP_SHADOW_LARGE_DESIGN_PX: f32 = 64.0; + #[derive(Debug, Default, Clone, Copy, PartialEq)] pub enum ShadowStyle { #[default] @@ -48,6 +60,32 @@ impl Shadow { self.hidden } + /// Approximate on-screen footprint (blur/spread + offset) at `scale` (zoom×dpr). + #[inline] + pub fn device_extent(&self, scale: f32) -> f32 { + let soft = self.blur.max(self.spread); + let offset = self.offset.0.abs().max(self.offset.1.abs()); + (soft + offset) * scale + } + + /// True when this shadow still has a perceptible footprint at `scale`. + /// Recursive shapes use a higher floor because compositing children into + /// the shadow layer is far more expensive than a leaf silhouette. + #[inline] + pub fn is_perceptible_at_scale(&self, scale: f32) -> bool { + self.is_perceptible_at_scale_for(scale, false) + } + + #[inline] + pub fn is_perceptible_at_scale_for(&self, scale: f32, recursive: bool) -> bool { + let min = if recursive { + DROP_SHADOW_RECURSIVE_MIN_DEVICE_PX + } else { + DROP_SHADOW_MIN_DEVICE_PX + }; + self.device_extent(scale) >= min + } + pub fn get_drop_shadow_filter(&self) -> Option { let sigma = radius_to_sigma(self.blur); let mut filter = image_filters::drop_shadow_only( @@ -112,3 +150,44 @@ impl Shadow { self.offset.1 *= value; } } + +#[cfg(test)] +mod tests { + use super::*; + + fn shadow(blur: f32, spread: f32, ox: f32, oy: f32) -> Shadow { + Shadow::new( + skia::Color::BLACK, + blur, + spread, + (ox, oy), + ShadowStyle::Drop, + false, + ) + } + + #[test] + fn leaf_floor_at_moderate_zoom() { + // blur 16 @ 0.13 ≈ 2.08px → keep leaf + assert!(shadow(16.0, 0.0, 0.0, 0.0).is_perceptible_at_scale_for(0.13, false)); + // blur 8 @ 0.13 ≈ 1.04px → skip leaf (below 2px) + assert!(!shadow(8.0, 0.0, 0.0, 0.0).is_perceptible_at_scale_for(0.13, false)); + } + + #[test] + fn recursive_floor_is_stricter() { + // blur 24 @ 0.13 ≈ 3.12px → keep leaf, skip recursive (needs 4px) + let s = shadow(24.0, 0.0, 0.0, 0.0); + assert!(s.is_perceptible_at_scale_for(0.13, false)); + assert!(!s.is_perceptible_at_scale_for(0.13, true)); + // blur 32 @ 0.13 ≈ 4.16px → keep recursive + assert!(shadow(32.0, 0.0, 0.0, 0.0).is_perceptible_at_scale_for(0.13, true)); + } + + #[test] + fn overview_scale_vs_extent() { + // At 0.038 even blur 50 is only ~1.9px — below leaf floor. + assert!(!shadow(50.0, 0.0, 0.0, 0.0).is_perceptible_at_scale_for(0.038, false)); + assert!(shadow(60.0, 0.0, 0.0, 0.0).is_perceptible_at_scale_for(0.038, false)); + } +} diff --git a/render-wasm/src/shapes/strokes.rs b/render-wasm/src/shapes/strokes.rs index f774a70457..2e80abafe0 100644 --- a/render-wasm/src/shapes/strokes.rs +++ b/render-wasm/src/shapes/strokes.rs @@ -7,6 +7,14 @@ use super::StrokeLineCap; use super::StrokeLineJoin; use super::SvgAttrs; +/// Soft floor in device pixels for dropping dash/dotted PathEffects when the +/// pattern period is effectively invisible. +pub const STROKE_MIN_DEVICE_PX: f32 = 0.75; + +/// When Inner/Outer doubled-width footprint is below this (device px), paint +/// as Center to avoid save_layer / Clear paths. +pub const STROKE_INNER_OUTER_SIMPLIFY_DEVICE_PX: f32 = 2.0; + #[derive(Debug, Clone, PartialEq, Copy)] pub enum StrokeStyle { Solid, @@ -69,6 +77,62 @@ impl Stroke { } } + /// Inner/Outer use a doubled-width Center stroke plus clip/clear. When that + /// footprint is thin on screen, fall back to a plain Center stroke. + #[inline] + pub fn simplified_kind_at_scale(&self, is_open: bool, scale: f32) -> StrokeKind { + let kind = self.render_kind(is_open); + match kind { + StrokeKind::Inner | StrokeKind::Outer + if 2.0 * self.max_width() * scale < STROKE_INNER_OUTER_SIMPLIFY_DEVICE_PX => + { + StrokeKind::Center + } + other => other, + } + } + + /// Drop dash/dotted PathEffects when the pattern period is subpixel. + #[inline] + pub fn style_at_scale(&self, scale: f32) -> StrokeStyle { + if self.style == StrokeStyle::Solid { + return StrokeStyle::Solid; + } + let period = match self.style { + StrokeStyle::Dotted => self.width + 5.0, + StrokeStyle::Dashed => { + let dash = self.dash.unwrap_or(self.width + 10.); + let gap = self.gap.unwrap_or(self.width + 10.); + dash.min(gap) + } + StrokeStyle::Mixed => self.width + 1.0, + StrokeStyle::Solid => return StrokeStyle::Solid, + }; + if period * scale < STROKE_MIN_DEVICE_PX { + StrokeStyle::Solid + } else { + self.style + } + } + + /// Path/Bool overview LOD: simplify Inner/Outer and dash/dotted at low + /// scale. Never skips painting; stroke-only icons would otherwise go blank. + pub fn path_lod_at_scale(&self, is_open: bool, scale: f32) -> Stroke { + let kind = self.simplified_kind_at_scale(is_open, scale); + let style = self.style_at_scale(scale); + let kind_unchanged = kind == self.render_kind(is_open); + let style_unchanged = style == self.style; + if kind_unchanged && style_unchanged { + return self.clone(); + } + let mut stroke = self.clone(); + if !is_open { + stroke.kind = kind; + } + stroke.style = style; + stroke + } + /// Per-side widths [top, right, bottom, left] when they actually differ. /// Returns `None` when unset or when all sides are equal, so the uniform /// render path (which supports dashed/dotted styles) keeps handling that @@ -493,4 +557,48 @@ mod tests { assert_eq!(stroke.widths, Some([2.0, 4.0, 6.0, 8.0])); assert_eq!(stroke.width, 4.0); } + + fn solid_center(width: f32) -> Stroke { + Stroke::new_center_stroke(width, StrokeStyle::Solid, None, None, None, None) + } + + #[test] + fn inner_outer_simplify_to_center_when_thin() { + let inner = Stroke::new_inner_stroke(8.0, StrokeStyle::Solid, None, None, None, None); + // 2 * 8 * 0.1 = 1.6 < 2.0, simplify to Center + assert_eq!( + inner.simplified_kind_at_scale(false, 0.1), + StrokeKind::Center + ); + // 2 * 8 * 0.2 = 3.2 >= 2.0, keep Inner + assert_eq!( + inner.simplified_kind_at_scale(false, 0.2), + StrokeKind::Inner + ); + } + + #[test] + fn dash_becomes_solid_when_period_subpixel() { + let dashed = + Stroke::new_center_stroke(2.0, StrokeStyle::Dashed, None, None, Some(20.0), Some(20.0)); + // period 20 * 0.03 = 0.6 < 0.75, solid + assert_eq!(dashed.style_at_scale(0.03), StrokeStyle::Solid); + // period 20 * 0.05 = 1.0 >= 0.75, keep dashed + assert_eq!(dashed.style_at_scale(0.05), StrokeStyle::Dashed); + } + + #[test] + fn path_lod_never_drops_thin_stroke() { + // Hairline strokes must still paint (stroke-only icons). + let thin = solid_center(1.0).path_lod_at_scale(false, 0.5); + assert_eq!(thin.width, 1.0); + assert_eq!(thin.kind, StrokeKind::Center); + } + + #[test] + fn path_lod_simplifies_inner_at_overview() { + let inner = Stroke::new_inner_stroke(8.0, StrokeStyle::Solid, None, None, None, None); + let lod = inner.path_lod_at_scale(false, 0.1); + assert_eq!(lod.kind, StrokeKind::Center); + } } diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index f5f58f5a80..f0f2fa9f1e 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -14,22 +14,64 @@ use skia_safe::{ textlayout::Affinity, textlayout::ParagraphBuilder, textlayout::ParagraphStyle, + textlayout::PlaceholderAlignment, + textlayout::PlaceholderStyle, textlayout::PositionWithAffinity, + textlayout::TextBaseline, Contains, }; +use std::borrow::Cow; use std::cell::Cell; use std::collections::HashSet; +use std::rc::Rc; use super::FontFamily; use crate::math::Point; -use crate::shapes::{self, merge_fills, Shape, VerticalAlign}; +use crate::shapes::{self, merge_fills, Shape, Type, VerticalAlign}; use crate::utils::{get_fallback_fonts, get_font_collection}; use crate::Uuid; // TODO: maybe move this to the wasm module? pub type ParagraphBuilderGroup = Vec; +/// True when the modifier changes the text layout container (resize), as opposed +/// to rotation/move where glyph layout can be reused. +pub fn modifier_changes_text_layout(base: &Shape, modifier: &Matrix) -> bool { + let Type::Text(text_content) = &base.shape_type else { + return false; + }; + let before = oriented_container_bounds(base); + let after = before.transform(modifier); + match text_content.grow_type() { + GrowType::AutoWidth => !crate::math::is_close_to(before.height(), after.height()), + GrowType::AutoHeight | GrowType::Fixed => { + !crate::math::is_close_to(before.width(), after.width()) + } + } +} + +fn oriented_container_bounds(shape: &Shape) -> Bounds { + let selrect = shape.selrect(); + let mut bounds = Bounds::new( + Point::new(selrect.x(), selrect.y()), + Point::new(selrect.x() + selrect.width(), selrect.y()), + Point::new( + selrect.x() + selrect.width(), + selrect.y() + selrect.height(), + ), + Point::new(selrect.x(), selrect.y() + selrect.height()), + ); + if !shape.transform.is_identity() { + let mut matrix = shape.transform; + let center = shape.center(); + matrix.post_translate(center); + matrix.pre_translate(-center); + bounds.transform_mut(&matrix); + } + bounds +} + #[repr(u8)] #[derive(Debug, PartialEq, Clone, Copy, ToJs)] pub enum GrowType { @@ -48,6 +90,11 @@ pub struct TextContentSize { const DEFAULT_TEXT_CONTENT_SIZE: f32 = 0.01; +/// Matches `marginRight: "1px"` on `.paragraph-set` in the HTML text renderer +/// (`frontend/src/app/main/ui/shapes/text/styles.cljs`). DOM `getBoundingClientRect` +/// includes that margin in auto-width measurements; Skia `longest_line()` does not. +const PARAGRAPH_SET_MARGIN_RIGHT: f32 = 1.0; + impl TextContentSize { pub fn default() -> Self { Self { @@ -150,7 +197,7 @@ impl TextPositionWithAffinity { } } - pub fn new_without_affinity(paragraph: usize, offset: usize) -> Self { + pub fn new_downstream_affinity(paragraph: usize, offset: usize) -> Self { Self { position_with_affinity: PositionWithAffinity { position: offset as i32, @@ -161,6 +208,17 @@ impl TextPositionWithAffinity { } } + pub fn new_upstream_affinity(paragraph: usize, offset: usize) -> Self { + Self { + position_with_affinity: PositionWithAffinity { + position: offset as i32, + affinity: Affinity::Upstream, + }, + paragraph, + offset, + } + } + pub fn reset(&mut self) { self.position_with_affinity.position = 0; self.position_with_affinity.affinity = Affinity::Downstream; @@ -193,7 +251,9 @@ struct CachedExtrect { #[derive(Debug)] pub struct TextContentLayout { pub paragraph_builders: Vec, - pub paragraphs: Vec>, + /// Shared across shape clones (e.g. modifier transforms) so rotation/pan + /// can paint without rebuilding Skia layout. Cleared builders on clone are OK. + pub paragraphs: Rc>>, cached_extrect: Cell>, } @@ -207,8 +267,8 @@ impl Clone for TextContentLayout { fn clone(&self) -> Self { Self { paragraph_builders: vec![], - paragraphs: vec![], - cached_extrect: Cell::new(None), + paragraphs: Rc::clone(&self.paragraphs), + cached_extrect: Cell::new(self.cached_extrect.get()), } } } @@ -223,7 +283,7 @@ impl TextContentLayout { pub fn new() -> Self { Self { paragraph_builders: vec![], - paragraphs: vec![], + paragraphs: Rc::new(Vec::new()), cached_extrect: Cell::new(None), } } @@ -234,12 +294,18 @@ impl TextContentLayout { paragraphs: Vec>, ) { self.paragraph_builders = paragraph_builders; - self.paragraphs = paragraphs; + self.paragraphs = Rc::new(paragraphs); + self.cached_extrect.set(None); + } + + pub fn clear(&mut self) { + self.paragraph_builders.clear(); + self.paragraphs = Rc::new(Vec::new()); self.cached_extrect.set(None); } pub fn needs_update(&self) -> bool { - self.paragraph_builders.is_empty() || self.paragraphs.is_empty() + self.paragraphs.is_empty() } } @@ -387,6 +453,20 @@ impl TextContent { self.bounds } + /// Text content for paint when [`Rect`] size may differ from stored bounds + /// (e.g. modifier transform). Reuses `self` when width/height match; otherwise + /// clones paragraphs into a rebound copy with an empty layout cache. + pub fn paint_content_for_selrect<'a>(&'a self, selrect: Rect) -> Cow<'a, Self> { + let stored_bounds = self.bounds(); + if (stored_bounds.width() - selrect.width()).abs() < 0.01 + && (stored_bounds.height() - selrect.height()).abs() < 0.01 + { + Cow::Borrowed(self) + } else { + Cow::Owned(self.new_bounds(selrect)) + } + } + pub fn set_xywh(&mut self, x: f32, y: f32, w: f32, h: f32) { self.bounds = Rect::from_xywh(x, y, w, h); } @@ -405,11 +485,19 @@ impl TextContent { seen } - pub fn add_paragraph(&mut self, paragraph: Paragraph) { + pub fn add_paragraph(&mut self, mut paragraph: Paragraph) { + let index = self.paragraphs.len() as u32; + paragraph.set_span_positions(index); self.paragraphs.push(paragraph); self.content_version = self.content_version.wrapping_add(1); } + pub fn reset_span_positions(&mut self) { + for (index, paragraph) in self.paragraphs.iter_mut().enumerate() { + paragraph.set_span_positions(index as u32); + } + } + pub fn paragraphs(&self) -> &[Paragraph] { &self.paragraphs } @@ -468,7 +556,7 @@ impl TextContent { let mut has_lines = false; let mut y_accum = base_y + vertical_offset; - for group in paragraphs { + for group in paragraphs.iter() { if let Some(paragraph) = group.first() { let line_metrics = paragraph.get_line_metrics(); for line in &line_metrics { @@ -518,7 +606,11 @@ impl TextContent { return self.content_rect(selrect, valign); } - let tight = if !self.layout.paragraphs.is_empty() { + let layout_matches_container = self + .layout_width + .is_some_and(|w| w.ceil() == self.get_width(selrect.width()).ceil()); + + let tight = if !self.layout.paragraphs.is_empty() && layout_matches_container { self.rect_from_paragraphs(selrect, valign) } else { let mut text_content = self.clone(); @@ -653,38 +745,14 @@ impl TextContent { let position_with_affinity = layout_paragraph.get_glyph_position_at_coordinate((para_pt.x, para_pt.y)); if let Some(paragraph) = self.paragraphs().get(paragraph_index) { - // Computed position keeps the current position in terms - // of number of characters of text. This is used to know - // in which span we are. - let mut computed_position: usize = 0; - - // If paragraph has no spans, default to span 0, offset 0 - if !paragraph.children().is_empty() { - for span in paragraph.children() { - let length = span.text.chars().count(); - let start_position = computed_position; - let end_position = computed_position + length; - let current_position = position_with_affinity.position as usize; - - // Handle empty spans: if the span is empty and current position - // matches the start, this is the right span - if length == 0 && current_position == start_position { - break; - } - - if start_position <= current_position - && end_position >= current_position - { - break; - } - computed_position += length; - } - } + // Skia reports UTF-16 code units, the model counts characters. + let offset = + paragraph.utf16_offset_to_char(position_with_affinity.position as usize); return Some(TextPositionWithAffinity::new( position_with_affinity, paragraph_index, - position_with_affinity.position as usize, + offset, )); } } @@ -728,62 +796,68 @@ impl TextContent { &self, use_shadow: Option, ) -> Vec { - let fonts = get_font_collection(); - let fallback_fonts = get_fallback_fonts(); - let mut paragraph_group = Vec::new(); - - for paragraph in self.paragraphs() { - let paragraph_style = paragraph.paragraph_to_style(); - let mut builder = ParagraphBuilder::new(¶graph_style, fonts); - let mut has_text = false; - for span in paragraph.children() { - let remove_alpha = use_shadow.unwrap_or(false) && !span.is_transparent(); - let text_style = span.to_style( - &self.bounds(), - fallback_fonts, - remove_alpha, - paragraph.line_height(), - ); - let text: String = span.apply_text_transform(); - if !text.is_empty() { - has_text = true; - } - builder.push_style(&text_style); - builder.add_text(&text); - } - if !has_text { - builder.add_text(" "); - } - paragraph_group.push(vec![builder]); - } - - paragraph_group + self.paragraph_builders(use_shadow, false, None, None) } /// Creates paragraph builders with always-opaque paint (BLACK @ alpha 255). /// Used as a clip mask for inner stroke rendering. pub fn paragraph_builder_group_opaque(&self) -> Vec { + self.paragraph_builders(None, true, None, None) + } + + /// Maximum number of stacked fills across every span in this text block. + pub fn max_fill_layers(&self) -> usize { + self.paragraphs() + .iter() + .flat_map(|p| p.children()) + .map(|s| s.fills.len()) + .max() + .unwrap_or(0) + } + + /// Builds paragraph builders that paint a single fill layer per span for SVG + /// export. `layer_from_bottom` is 0 for the bottommost fill (fills[last]). + pub fn paragraph_builder_group_for_fill_layer( + &self, + layer_from_bottom: usize, + ) -> Vec { + self.paragraph_builders(None, false, None, Some(layer_from_bottom)) + } + + fn paragraph_builders( + &self, + use_shadow: Option, + opaque: bool, + align_override: Option, + fill_layer: Option, + ) -> Vec { let fonts = get_font_collection(); let fallback_fonts = get_fallback_fonts(); let mut paragraph_group = Vec::new(); for paragraph in self.paragraphs() { - let paragraph_style = paragraph.paragraph_to_style(); + let mut paragraph_style = paragraph.paragraph_to_style(); + if let Some(align) = align_override { + paragraph_style.set_text_align(align); + } let mut builder = ParagraphBuilder::new(¶graph_style, fonts); let mut has_text = false; for span in paragraph.children() { - let text_style = span.to_style( + let remove_alpha = + opaque || (use_shadow.unwrap_or(false) && !span.is_transparent()); + let text_style = span.to_style_with_paint( &self.bounds(), fallback_fonts, - true, // always opaque + remove_alpha, paragraph.line_height(), + fill_layer, ); let text: String = span.apply_text_transform(); if !text.is_empty() { has_text = true; } builder.push_style(&text_style); - builder.add_text(&text); + add_text_with_tabs(&mut builder, &text, span.font_size); } if !has_text { builder.add_text(" "); @@ -796,29 +870,40 @@ impl TextContent { /// Performs an Auto Width text layout. fn text_layout_auto_width(&self) -> TextContentLayoutResult { - let mut paragraph_builders = self.paragraph_builder_group_from_text(None); + // Left-aligned MAX-width pass: longest_line() is glyph width, not the huge container. + let mut measure_builders = + self.paragraph_builders(None, false, Some(skia::textlayout::TextAlign::Left), None); let normalized_line_height = - calculate_normalized_line_height(&mut paragraph_builders, f32::MAX); + calculate_normalized_line_height(&mut measure_builders, f32::MAX); + let measure_paragraphs = + build_paragraphs_from_paragraph_builders(&mut measure_builders, f32::MAX); + + let content_width = measure_paragraphs + .iter() + .flatten() + .fold(0.0_f32, |auto_width, paragraph| { + f32::max(paragraph.longest_line(), auto_width) + }) + .ceil(); + + // Re-layout at the intrinsic width (without the HTML margin slack). + let mut paragraph_builders = self.paragraph_builder_group_from_text(None); let paragraphs = - build_paragraphs_from_paragraph_builders(&mut paragraph_builders, f32::MAX); - - let (width, height) = - paragraphs - .iter() - .flatten() - .fold((0.0, 0.0), |(auto_width, auto_height), paragraph| { - ( - f32::max(paragraph.longest_line(), auto_width), - auto_height + paragraph.height(), - ) - }); + build_paragraphs_from_paragraph_builders(&mut paragraph_builders, content_width); + let height = paragraphs + .iter() + .flatten() + .fold(0.0_f32, |auto_height, paragraph| { + auto_height + paragraph.height() + }); + let reported_width = content_width + PARAGRAPH_SET_MARGIN_RIGHT; let size = TextContentSize::new_with_normalized_line_height( - width.ceil(), + reported_width, height.ceil(), - width.ceil(), + reported_width, normalized_line_height, ); TextContentLayoutResult(paragraph_builders, paragraphs, size) @@ -898,6 +983,41 @@ impl TextContent { self.layout.needs_update() } + /// True when cached Skia paragraphs can be painted as-is (no rebuild/layout). + pub fn has_usable_paint_layout(&self, shape: &Shape) -> bool { + if self.layout.needs_update() || self.layout_version != self.content_version { + return false; + } + self.layout_matches_paint_container(shape) + } + + pub(crate) fn layout_cache_versions_match(&self) -> bool { + !self.layout.needs_update() && self.layout_version == self.content_version + } + + pub(crate) fn layout_matches_paint_container(&self, shape: &Shape) -> bool { + if self.grow_type() == GrowType::AutoWidth { + return true; + } + let Some(layout_w) = self.layout_width else { + return false; + }; + let container_w = self.get_width(shape.selrect().width()); + (layout_w - container_w).abs() < f32::EPSILON + } + + /// True when any span requests underline/overline/line-through (custom draw path). + pub fn has_text_decorations(&self) -> bool { + self.paragraphs().iter().any(|paragraph| { + paragraph.children().iter().any(|span| { + matches!( + span.text_decoration, + Some(d) if d != skia::textlayout::TextDecoration::NO_DECORATION + ) + }) + }) + } + pub fn set_layout_from_result( &mut self, result: TextContentLayoutResult, @@ -912,14 +1032,22 @@ impl TextContent { pub fn force_next_layout_update(&mut self) { self.layout_width = None; self.layout.cached_extrect.set(None); + // Bump the content version so update_layout can't early-return: auto-width + // shapes always match their container and clearing the cache above doesn't + // flip needs_update(), so a late font resolution would otherwise be skipped. + self.content_version = self.content_version.wrapping_add(1); } pub fn update_layout(&mut self, selrect: Rect) -> TextContentSize { + // Auto-width ignores selrect width so get-text-dimensions can reuse the cached layout. + let layout_matches_container = self.grow_type() == GrowType::AutoWidth + || self + .layout_width + .is_some_and(|w| (w - selrect.width()).abs() < f32::EPSILON); + if !self.layout.needs_update() && self.layout_version == self.content_version - && self - .layout_width - .is_some_and(|w| (w - selrect.width()).abs() < f32::EPSILON) + && layout_matches_container { return self.size; } @@ -1160,6 +1288,59 @@ impl Paragraph { &mut self.children } + fn set_span_positions(&mut self, index: u32) { + for (span_index, span) in self.children.iter_mut().enumerate() { + span.set_position(index, span_index as u32); + } + } + + fn char_count(&self) -> usize { + self.children + .iter() + .map(|span| span.text.chars().count()) + .sum() + } + + /// Translate a character offset into the UTF-16 offset Skia indexes by. + /// Both differ on astral-plane characters (emoji) and whenever a text + /// transform changes the length of the laid out text (`ß` -> `SS`). + pub fn char_offset_to_utf16(&self, char_offset: usize) -> usize { + let mut remaining = char_offset; + let mut utf16 = 0; + for span in &self.children { + if remaining == 0 { + break; + } + let span_len = span.text.chars().count(); + let take = remaining.min(span_len); + let prefix: String = span.text.chars().take(take).collect(); + utf16 += span.transform_text(&prefix).encode_utf16().count(); + remaining -= take; + } + utf16 + } + + /// Translate a UTF-16 offset coming from Skia into a character offset. + /// An offset inside a character rounds up, so it never splits a glyph. + pub fn utf16_offset_to_char(&self, utf16_offset: usize) -> usize { + let (mut low, mut high) = (0, self.char_count()); + while low < high { + let middle = (low + high) / 2; + if self.char_offset_to_utf16(middle) < utf16_offset { + low = middle + 1; + } else { + high = middle; + } + } + low + } + + /// UTF-16 length of the character at `char_offset`, so a caret range covers + /// the whole glyph. + pub fn char_utf16_len_at(&self, char_offset: usize) -> usize { + self.char_offset_to_utf16(char_offset + 1) - self.char_offset_to_utf16(char_offset) + } + pub fn line_height(&self) -> f32 { self.line_height } @@ -1190,7 +1371,7 @@ impl Paragraph { style.set_height(self.line_height); style.set_text_align(self.text_align); style.set_text_direction(self.text_direction); - style.set_replace_tab_characters(true); + style.set_replace_tab_characters(false); style.set_apply_rounding_hack(true); style.set_text_height_behavior(skia::textlayout::TextHeightBehavior::All); style @@ -1226,12 +1407,30 @@ fn capitalize_words(text: &str) -> String { result } -/// Filter control characters below U+0020, preserving line breaks. +/// Add `text`, pushing every '\t' as a one em wide placeholder. +pub fn add_text_with_tabs(builder: &mut ParagraphBuilder, text: &str, font_size: f32) { + let tab = PlaceholderStyle::new( + font_size, + 0.0, + PlaceholderAlignment::Baseline, + TextBaseline::Alphabetic, + 0.0, + ); + + for (index, segment) in text.split('\t').enumerate() { + if index > 0 { + builder.add_placeholder(&tab); + } + builder.add_text(segment); + } +} + +/// Filter control characters below U+0020, preserving tabs and line breaks. /// Browser-dependent: Firefox drops them, others replace with space. fn process_ignored_chars(text: &str, browser: u8) -> String { text.chars() .filter_map(|c| { - if c == '\n' || c == '\r' || c == '\u{2028}' || c == '\u{2029}' { + if c == '\t' || c == '\n' || c == '\r' || c == '\u{2028}' || c == '\u{2029}' { return Some(c); } if c < '\u{0020}' { @@ -1260,6 +1459,8 @@ pub struct TextSpan { pub text_transform: Option, pub text_direction: TextDirection, pub fills: Vec, + pub paragraph_position: u32, + pub span_position: u32, } impl TextSpan { @@ -1289,6 +1490,8 @@ impl TextSpan { font_weight, font_variant_id, fills, + paragraph_position: u32::MAX, + span_position: u32::MAX, } } @@ -1296,6 +1499,11 @@ impl TextSpan { self.text = text; } + pub fn set_position(&mut self, paragraph: u32, span: u32) { + self.paragraph_position = paragraph; + self.span_position = span; + } + pub fn to_style( &self, content_bounds: &Rect, @@ -1303,15 +1511,41 @@ impl TextSpan { remove_alpha: bool, paragraph_line_height: f32, ) -> skia::textlayout::TextStyle { - let mut style = skia::textlayout::TextStyle::default(); - let mut paint = paint::Paint::default(); + self.to_style_with_paint( + content_bounds, + fallback_fonts, + remove_alpha, + paragraph_line_height, + None, + ) + } - if remove_alpha { + fn to_style_with_paint( + &self, + content_bounds: &Rect, + fallback_fonts: &HashSet, + remove_alpha: bool, + paragraph_line_height: f32, + fill_layer_from_bottom: Option, + ) -> skia::textlayout::TextStyle { + let mut style = skia::textlayout::TextStyle::default(); + let paint = if remove_alpha { + let mut paint = paint::Paint::default(); paint.set_color(skia::Color::BLACK); paint.set_alpha(255); + paint + } else if let Some(layer) = fill_layer_from_bottom { + if layer < self.fills.len() { + let fill_idx = self.fills.len() - 1 - layer; + self.fills[fill_idx].to_paint(content_bounds, true) + } else { + let mut paint = paint::Paint::default(); + paint.set_color(skia::Color::TRANSPARENT); + paint + } } else { - paint = merge_fills(&self.fills, *content_bounds); - } + merge_fills(&self.fills, *content_bounds) + }; let max_line_height = f32::max(paragraph_line_height, self.line_height); style.set_height(max_line_height); @@ -1377,9 +1611,8 @@ impl TextSpan { format!("{}", self.font_family) } - pub fn apply_text_transform(&self) -> String { - let browser = crate::with_state!(state, { state.current_browser }); - let text = process_ignored_chars(&self.text, browser); + pub fn transform_text(&self, text: &str) -> String { + let text = process_ignored_chars(text, crate::globals::current_browser()); match self.text_transform { Some(TextTransform::Uppercase) => text.to_uppercase(), Some(TextTransform::Lowercase) => text.to_lowercase(), @@ -1388,6 +1621,10 @@ impl TextSpan { } } + pub fn apply_text_transform(&self) -> String { + self.transform_text(&self.text) + } + pub fn scale_content(&mut self, value: f32) { self.font_size *= value; } @@ -1716,6 +1953,15 @@ mod tests { assert_eq!(process_ignored_chars("hello\rworld", 0), "hello\rworld"); } + #[test] + fn process_ignored_chars_preserves_tabs() { + assert_eq!(process_ignored_chars("hello\tworld", 0), "hello\tworld"); + assert_eq!( + process_ignored_chars("hello\tworld", Browser::Firefox as u8), + "hello\tworld" + ); + } + #[test] fn process_ignored_chars_replaces_control_chars_chrome() { // U+0001 (SOH) should become space in non-Firefox @@ -1732,4 +1978,211 @@ mod tests { "ab" ); } + + fn test_paragraph(texts: &[&str]) -> Paragraph { + let spans = texts + .iter() + .map(|text| { + TextSpan::new( + text.to_string(), + FontFamily::new(Uuid::nil(), 400, crate::shapes::FontStyle::Normal), + 14.0, + 1.2, + 0.0, + None, + None, + TextDirection::LTR, + 400, + Uuid::nil(), + vec![], + ) + }) + .collect(); + + Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.2, + 0.0, + spans, + ) + } + + #[test] + fn char_offsets_match_utf16_offsets_for_bmp_text() { + let para = test_paragraph(&["Añadir"]); + for offset in 0..=6 { + assert_eq!(para.char_offset_to_utf16(offset), offset); + assert_eq!(para.utf16_offset_to_char(offset), offset); + } + } + + #[test] + fn char_offsets_account_for_astral_characters() { + let para = test_paragraph(&["a", "😀b"]); + + assert_eq!(para.char_offset_to_utf16(0), 0); + assert_eq!(para.char_offset_to_utf16(1), 1); + assert_eq!(para.char_offset_to_utf16(2), 3); + assert_eq!(para.char_offset_to_utf16(3), 4); + + assert_eq!(para.utf16_offset_to_char(0), 0); + assert_eq!(para.utf16_offset_to_char(1), 1); + assert_eq!(para.utf16_offset_to_char(3), 2); + assert_eq!(para.utf16_offset_to_char(4), 3); + } + + #[test] + fn utf16_offset_inside_a_surrogate_pair_rounds_to_a_char_boundary() { + let para = test_paragraph(&["a😀b"]); + assert_eq!(para.utf16_offset_to_char(2), 2); + } + + #[test] + fn char_offsets_account_for_text_transforms() { + // Skia lays out the transformed text, where "Straße" is "STRASSE". + let mut para = test_paragraph(&["Straße"]); + para.children_mut()[0].text_transform = Some(TextTransform::Uppercase); + + assert_eq!(para.char_offset_to_utf16(4), 4); + assert_eq!(para.char_offset_to_utf16(6), 7); + assert_eq!(para.char_utf16_len_at(4), 2); + assert_eq!(para.utf16_offset_to_char(7), 6); + } + + #[test] + fn char_utf16_len_at_covers_the_whole_glyph() { + let para = test_paragraph(&["a😀b"]); + assert_eq!(para.char_utf16_len_at(0), 1); + assert_eq!(para.char_utf16_len_at(1), 2); + assert_eq!(para.char_utf16_len_at(2), 1); + } + + fn sample_text_content() -> TextContent { + let bounds = Rect::from_xywh(0.0, 0.0, 200.0, 100.0); + let mut content = TextContent::new(bounds, GrowType::Fixed); + content.add_paragraph(test_paragraph(&["hello"])); + content + } + + #[test] + fn has_usable_paint_layout_false_when_paragraphs_empty() { + let content = TextContent::new(Rect::from_xywh(0.0, 0.0, 100.0, 50.0), GrowType::Fixed); + let shape = Shape::new(Uuid::nil()); + assert!(!content.has_usable_paint_layout(&shape)); + } + + #[test] + fn has_usable_paint_layout_false_when_versions_mismatch() { + let mut content = sample_text_content(); + content.layout.paragraphs = Rc::new(vec![vec![]]); + content.layout_width = Some(200.0); + content.layout_version = 1; + content.content_version = 2; + let mut shape = Shape::new(Uuid::nil()); + shape.set_selrect(0.0, 0.0, 200.0, 100.0); + assert!(!content.has_usable_paint_layout(&shape)); + } + + #[test] + fn has_usable_paint_layout_true_when_cached_and_versions_match() { + let mut content = sample_text_content(); + content.layout.paragraphs = Rc::new(vec![vec![]]); + content.layout_width = Some(200.0); + content.layout_version = 3; + content.content_version = 3; + let mut shape = Shape::new(Uuid::nil()); + shape.set_selrect(0.0, 0.0, 200.0, 100.0); + assert!(content.has_usable_paint_layout(&shape)); + } + + #[test] + fn has_usable_paint_layout_false_when_selrect_width_changed() { + let mut content = sample_text_content(); + content.layout.paragraphs = Rc::new(vec![vec![]]); + content.layout_width = Some(200.0); + content.layout_version = 3; + content.content_version = 3; + let mut shape = Shape::new(Uuid::nil()); + shape.set_selrect(0.0, 0.0, 300.0, 100.0); + assert!(!content.has_usable_paint_layout(&shape)); + } + + fn text_shape_with_cached_layout(content: TextContent) -> Shape { + let mut shape = Shape::new(Uuid::nil()); + shape.set_shape_type(shapes::Type::Text(content)); + shape.set_selrect(0.0, 0.0, 200.0, 100.0); + shape + } + + #[test] + fn has_usable_paint_layout_false_when_rotated_and_resized() { + let mut content = sample_text_content(); + content.layout.paragraphs = Rc::new(vec![vec![]]); + content.layout_width = Some(200.0); + content.layout_version = 3; + content.content_version = 3; + let base_shape = text_shape_with_cached_layout(content); + let rotate = Matrix::rotate_deg(45.0); + let resize = Matrix::scale((1.5, 1.0)); + let mut modifier = rotate; + modifier.pre_concat(&resize); + assert!(modifier_changes_text_layout(&base_shape, &modifier)); + } + + #[test] + fn has_text_decorations_detects_underline() { + let mut content = sample_text_content(); + content.paragraphs_mut()[0].children_mut()[0].text_decoration = + Some(skia::textlayout::TextDecoration::UNDERLINE); + assert!(content.has_text_decorations()); + } + + #[test] + fn has_text_decorations_false_for_plain_text() { + let content = sample_text_content(); + assert!(!content.has_text_decorations()); + } + + #[test] + fn paint_content_for_selrect_borrows_when_bounds_match() { + let content = sample_text_content(); + let selrect = Rect::from_xywh(10.0, 20.0, 200.0, 100.0); + match content.paint_content_for_selrect(selrect) { + Cow::Borrowed(_) => {} + Cow::Owned(_) => panic!("expected borrowed content"), + } + } + + #[test] + fn paint_content_for_selrect_rebounds_when_size_differs() { + let content = sample_text_content(); + let selrect = Rect::from_xywh(0.0, 0.0, 300.0, 100.0); + match content.paint_content_for_selrect(selrect) { + Cow::Owned(rebound) => { + assert_eq!(rebound.bounds().width(), 300.0); + assert!(rebound.layout.needs_update()); + } + Cow::Borrowed(_) => panic!("expected rebound content"), + } + } + + #[test] + fn layout_clone_shares_skia_paragraphs() { + let mut layout = TextContentLayout::new(); + layout.paragraphs = Rc::new(vec![vec![]]); + let cloned = layout.clone(); + assert!(Rc::ptr_eq(&layout.paragraphs, &cloned.paragraphs)); + assert!(cloned.paragraph_builders.is_empty()); + } + + #[test] + fn layout_clear_empties_paragraphs() { + let mut layout = TextContentLayout::new(); + layout.paragraphs = Rc::new(vec![vec![]]); + layout.clear(); + assert!(layout.needs_update()); + } } diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 849d35131b..5b2684ecb4 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -105,6 +105,10 @@ impl State { crate::render::pdf::render_to_pdf(get_resources(), id, &self.shapes, scale) } + pub fn render_shape_svg(&mut self, id: &Uuid, scale: f32) -> Result> { + crate::render::svg::render_to_svg(get_resources(), id, &self.shapes, scale) + } + /// GPU-free counterpart of [`State::render_shape_pixels`]: encodes to /// `format` on a CPU raster surface, no GPU/WebGL. pub fn render_shape_raster( @@ -198,22 +202,24 @@ impl State { // headless export path has none, so skip it there. if has_render_state() { let render_state = get_render_state(); - // IMPORTANT: - // Do NOT use `get_tiles_for_shape` here. That method intersects the shape - // tiles with the current interest area, which means we'd only invalidate - // the subset currently near the viewport. When the user later pans/zooms - // to reveal previously cached tiles, stale pixels could reappear. - // - // Instead, remove the shape from *all* tiles where it was indexed, and - // drop cached tiles for those entries. + // Do NOT use `get_tiles_for_shape` (interest-clipped). Evict by + // document coverage so cached tiles outside the interest area + // cannot keep pixels of the deleted shape. let indexed_tiles: Vec = render_state .tiles .get_tiles_of(shape.id) .map(|t| t.iter().copied().collect()) .unwrap_or_default(); - + let scale = render_state.get_scale(); + let dirty = indexed_tiles + .iter() + .fold(shape.extrect(&self.shapes, 1.0), |acc, tile| { + tiles::join_nonempty(acc, tiles::get_tile_rect(*tile, scale)) + }); + render_state + .surfaces + .invalidate_cached_tiles_intersecting(dirty); for tile in indexed_tiles { - render_state.remove_cached_tile(tile); render_state.tiles.remove_shape_at(tile, shape.id); } } @@ -338,21 +344,51 @@ impl State { self.shapes.set_modifiers(modifiers); } - pub fn touch_current(&mut self) { - // `mark_touched` only drives incremental on-screen tile invalidation; - // the headless export path has no render state, so skip it there. - if self.loading || !has_render_state() { - return; + /// Replace the current shape's children list (same semantics as `_set_children`). + pub fn set_current_shape_children(&mut self, entries: Vec) -> Result<()> { + let (parent_id, deleted) = { + let Some(shape) = self.current_shape_mut() else { + return Err(Error::RecoverableError( + "set_current_shape_children: no current shape".to_string(), + )); + }; + + let id = shape.id; + let (_, deleted) = shape.compute_children_differences(&entries); + shape.children = entries.clone(); + (id, deleted) + }; + + for id in &entries { + self.touch_shape(*id); + if let Some(children_shape) = self.shapes.get_mut(id) { + children_shape.set_deleted(false); + } } + + for id in deleted { + self.delete_shape_children(parent_id, id); + self.touch_shape(id); + } + + Ok(()) + } + + pub fn touch_current(&mut self) { if let Some(current_id) = self.current_id { - get_render_state().mark_touched(current_id); + self.touch_shape(current_id); } } pub fn touch_shape(&mut self, id: Uuid) { + self.shapes.invalidate_ancestors_extrect(&id); if self.loading || !has_render_state() { return; } - get_render_state().mark_touched(id); + let prev = self + .shapes + .get(&id) + .map(|shape| shape.extrect(&self.shapes, 1.0)); + get_render_state().mark_touched_with_prev(id, prev); } } diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index f57bbcb51b..d09bb109b2 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -153,6 +153,15 @@ impl ShapesPoolImpl { self.modifiers.get(&idx) } + /// Modifier applied to `id`, including one inherited from an ancestor. + pub fn get_layout_modifier(&self, id: &Uuid) -> Option { + if let Some(matrix) = self.get_modifier(id) { + return Some(*matrix); + } + let idx = *self.uuid_to_idx.get(id)?; + self.find_nearest_ancestor_modifier(idx) + } + /// Get a shape by UUID without applying modifiers/structure/scale-content. pub fn get_raw(&self, id: &Uuid) -> Option<&Shape> { let idx = *self.uuid_to_idx.get(id)?; @@ -239,6 +248,51 @@ impl ShapesPoolImpl { self.modified_shape_cache.clear() } + pub fn dependent_ancestor_ids<'a>(&'a self, id: &Uuid) -> impl Iterator + 'a { + let mut current = self + .uuid_to_idx + .get(id) + .and_then(|idx| self.shapes[*idx].parent_id); + + std::iter::from_fn(move || { + let parent_id = current.filter(|parent_id| !parent_id.is_nil())?; + let parent_idx = self.uuid_to_idx.get(&parent_id).copied()?; + let parent = &self.shapes[parent_idx]; + if !parent.extrect_depends_on_children() { + return None; + } + current = parent.parent_id; + Some(parent_id) + }) + } + + /// Drops the extrect cache of every ancestor whose extrect is affected by this shape + /// stopping at the first ancestor that clips. + pub fn invalidate_ancestors_extrect(&mut self, id: &Uuid) { + let mut current = self + .uuid_to_idx + .get(id) + .and_then(|idx| self.shapes[*idx].parent_id); + + while let Some(parent_id) = current.filter(|parent_id| !parent_id.is_nil()) { + let Some(parent_idx) = self.uuid_to_idx.get(&parent_id).copied() else { + break; + }; + if !self.shapes[parent_idx].extrect_depends_on_children() { + break; + } + + self.shapes[parent_idx].invalidate_extrect(); + // `get` returns a snapshot clone, we need to get mut + // and replace the OnceCell to reset it. + if let Some(cell) = self.modified_shape_cache.get_mut(&parent_idx) { + *cell = OnceCell::new(); + } + + current = self.shapes[parent_idx].parent_id; + } + } + pub fn set_modifiers(&mut self, modifiers: HashMap) { let mut ids = Vec::::new(); let mut modifiers_with_idx = HashMap::with_capacity(modifiers.len()); @@ -254,10 +308,8 @@ impl ShapesPoolImpl { // When CLJS sends only root shapes (translation on drag), descendants // need the same matrix. // For resize/rotate, propagate-modifiers already includes all descendants. - // Descendants are NOT pushed into `ids` / `modifier_uuids`: tile invalidation - // via rebuild_modifier_tiles only runs for roots, which is sufficient because - // descendants always lie inside the parent's bounding box and are therefore - // covered by the parent's old/new tile ranges. + // Descendants are NOT pushed into `ids` / `modifier_uuids`: rebuild_modifier_tiles + // runs for roots, and drops the non-clipping ancestors' extrects separately. let root_pairs: Vec<(usize, skia::Matrix)> = ids .iter() .filter_map(|uuid| { @@ -289,13 +341,15 @@ impl ShapesPoolImpl { // Compute ancestors before consuming `ids` so we can move it into // `modifier_uuids` without a clone. let all_ids = shapes::all_with_ancestors(&ids, self, true); - // rebuild_modifier_tiles doesn't process every descendant individually. - self.modifier_uuids = ids; + for uuid in all_ids { if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() { self.modified_shape_cache.insert(idx, OnceCell::new()); } } + + // rebuild_modifier_tiles doesn't process every descendant individually. + self.modifier_uuids = ids; } pub fn set_structure(&mut self, structure: HashMap>) { diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 3e46f77116..73711a93af 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -9,10 +9,7 @@ use crate::shapes::{ use crate::uuid::Uuid; use crate::wasm::text::helpers::{self as text_helpers, find_text_span_at_offset}; use crate::wasm::text_editor::CursorDirection; -use skia_safe::{ - textlayout::{Affinity, PositionWithAffinity}, - Color, -}; +use skia_safe::Color; #[derive(Debug, Clone, Copy, Default)] pub struct TextSelection { @@ -329,7 +326,7 @@ impl TextComposition { let focus = selection.focus; let previous_len = self.previous.chars().count(); - let anchor = TextPositionWithAffinity::new_without_affinity( + let anchor = TextPositionWithAffinity::new_downstream_affinity( focus.paragraph, focus.offset + previous_len, ); @@ -437,9 +434,22 @@ impl TextEditorState { true } - pub fn select_all(&mut self, text_content: &TextContent) -> bool { + fn select_range( + &mut self, + text_content: &TextContent, + start: &TextPositionWithAffinity, + end: &TextPositionWithAffinity, + ) { self.is_pointer_selection_active = false; - self.set_caret_from_position(&TextPositionWithAffinity::empty()); + self.is_click_event_skipped = false; + self.set_caret_from_position(start); + self.extend_selection_from_position(end); + self.update_styles(text_content); + self.reset_blink(); + self.push_event(TextEditorEvent::SelectionChanged); + } + + pub fn select_all(&mut self, text_content: &TextContent) -> bool { let num_paragraphs = text_content.paragraphs().len().saturating_sub(1); let Some(last_paragraph) = text_content.paragraphs().last() else { return false; @@ -447,21 +457,13 @@ impl TextEditorState { let Some(_last_text_span) = last_paragraph.children().last() else { return false; }; - let mut offset = 0; - for span in last_paragraph.children() { - offset += span.text.len(); - } - self.extend_selection_from_position(&TextPositionWithAffinity::new( - PositionWithAffinity { - position: offset as i32, - affinity: Affinity::Upstream, - }, - num_paragraphs, - offset, - )); - self.update_styles(text_content); - self.reset_blink(); - self.push_event(TextEditorEvent::SelectionChanged); + // Offsets are counted in characters, not bytes. + let offset = text_helpers::paragraph_char_count(last_paragraph); + self.select_range( + text_content, + &TextPositionWithAffinity::empty(), + &TextPositionWithAffinity::new_upstream_affinity(num_paragraphs, offset), + ); true } @@ -471,8 +473,6 @@ impl TextEditorState { text_content: &TextContent, position: &TextPositionWithAffinity, ) { - self.is_pointer_selection_active = false; - let paragraphs = text_content.paragraphs(); if paragraphs.is_empty() || position.paragraph >= paragraphs.len() { return; @@ -487,7 +487,7 @@ impl TextEditorState { let chars: Vec = paragraph_text.chars().collect(); if chars.is_empty() { - self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( + self.set_caret_from_position(&TextPositionWithAffinity::new_downstream_affinity( position.paragraph, 0, )); @@ -509,7 +509,7 @@ impl TextEditorState { } if !text_helpers::is_word_char(chars[offset]) { - self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( + self.set_caret_from_position(&TextPositionWithAffinity::new_downstream_affinity( position.paragraph, position.offset.min(chars.len()), )); @@ -529,17 +529,31 @@ impl TextEditorState { end += 1; } - self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( - position.paragraph, - start, - )); - self.extend_selection_from_position(&TextPositionWithAffinity::new_without_affinity( - position.paragraph, - end, - )); - self.update_styles(text_content); - self.reset_blink(); - self.push_event(TextEditorEvent::SelectionChanged); + self.select_range( + text_content, + &TextPositionWithAffinity::new_downstream_affinity(position.paragraph, start), + &TextPositionWithAffinity::new_downstream_affinity(position.paragraph, end), + ); + } + + pub fn select_paragraph( + &mut self, + text_content: &TextContent, + position: &TextPositionWithAffinity, + ) { + let paragraphs = text_content.paragraphs(); + if paragraphs.is_empty() || position.paragraph >= paragraphs.len() { + return; + } + + // Offsets are counted in characters, not bytes. + let offset = text_helpers::paragraph_char_count(¶graphs[position.paragraph]); + + self.select_range( + text_content, + &TextPositionWithAffinity::new_downstream_affinity(position.paragraph, 0), + &TextPositionWithAffinity::new_upstream_affinity(position.paragraph, offset), + ); } pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) { @@ -798,8 +812,7 @@ impl TextEditorState { } } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); self.reset_blink(); self.push_event(TextEditorEvent::ContentChanged); @@ -824,8 +837,7 @@ impl TextEditorState { self.selection.set_caret(clamped); } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); self.reset_blink(); self.push_event(TextEditorEvent::ContentChanged); @@ -842,12 +854,11 @@ impl TextEditorState { let cursor = self.selection.focus; if text_helpers::split_paragraph_at_cursor(text_content, &cursor) { let new_cursor = - TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0); + TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph + 1, 0); self.selection.set_caret(new_cursor); } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); self.reset_blink(); self.push_event(TextEditorEvent::ContentChanged); diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index d00dc25bdf..ea36363f6d 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -258,6 +258,35 @@ pub fn get_tile_rect(tile: Tile, scale: f32) -> skia::Rect { skia::Rect::from_xywh(tx, ty, ts, ts) } +/// Physical atlas cell size so `needed_slots` fit in a square `atlas_px` +/// texture. Never larger than `TILE_SIZE` (tiles are stored 1:1 when they +/// fit). Smaller cells mean more slots, scaled down on blit into the atlas. +pub fn tile_atlas_slot_size(needed_slots: usize, atlas_px: i32) -> i32 { + const MIN_SLOT: i32 = 64; + let needed = needed_slots.max(1); + let side = (needed as f64).sqrt().ceil() as i32; + let side = side.max(1); + (atlas_px / side).clamp(MIN_SLOT, TILE_SIZE as i32) +} + +/// Inset (texels) applied when sampling a packed atlas slot with Linear +/// filtering, so upsample kernels do not bleed into the neighboring cell. +pub const TILE_ATLAS_SAMPLE_INSET: f32 = 1.0; + +/// Source size inside a packed slot after the Linear-filter inset. +pub fn tile_atlas_compose_src_size(slot_size: i32) -> f32 { + if slot_size < TILE_SIZE as i32 { + (slot_size as f32 - 2.0 * TILE_ATLAS_SAMPLE_INSET).max(1.0) + } else { + slot_size as f32 + } +} + +/// `draw_atlas` scale so the destination sprite stays `TILE_SIZE` after inset. +pub fn tile_atlas_compose_scale(slot_size: i32) -> f32 { + TILE_SIZE / tile_atlas_compose_src_size(slot_size) +} + // This structure is useful to keep all the shape uuids by shape id. pub struct TileHashMap { grid: HashMap>, @@ -323,6 +352,8 @@ pub struct PendingTiles { pub visible_uncached: Vec, pub interest_cached: Vec, pub interest_uncached: Vec, + /// Interest-ring tiles deferred until after the viewport has been presented. + deferred_interest: Vec, } impl PendingTiles { @@ -335,14 +366,22 @@ impl PendingTiles { visible_uncached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), interest_cached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), interest_uncached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), + deferred_interest: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), } } - pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces, only_visible: bool) { + pub fn update( + &mut self, + tile_viewbox: &TileViewbox, + surfaces: &Surfaces, + scale: f32, + only_visible: bool, + ) { self.list.clear(); + self.deferred_interest.clear(); // During interactive transform, skip the interest-area ring - // entirely — the user is dragging, every rAF is on the critical + // entirely: the user is dragging, every rAF is on the critical // path, and pre-rendering tiles outside the viewport is wasted // work that just gets evicted on the next pointer move. The ring // is repopulated naturally on gesture end / on idle rAFs. @@ -384,7 +423,7 @@ impl PendingTiles { for (_, tile) in self.tile_order.iter() { let tile = *tile; let is_visible = tile_viewbox.visible_rect.contains(&tile); - let is_cached = surfaces.has_cached_tile_surface(tile); + let is_cached = surfaces.has_cached_tile_surface(tile, scale); match (is_visible, is_cached) { (true, true) => self.visible_cached.push(tile), @@ -394,13 +433,107 @@ impl PendingTiles { } } - self.list.extend(self.interest_uncached.iter()); - self.list.extend(self.interest_cached.iter()); - self.list.extend(self.visible_uncached.iter()); - self.list.extend(self.visible_cached.iter()); + // Visible tiles first. Interest-ring work is deferred so we can present + // as soon as the viewport is ready (see `promote_deferred_interest`). + // Interactive/`only_visible` already excludes the ring from `tile_rect`. + if only_visible { + self.list.extend(self.visible_uncached.iter()); + self.list.extend(self.visible_cached.iter()); + } else { + self.deferred_interest.extend(self.interest_uncached.iter()); + self.deferred_interest.extend(self.interest_cached.iter()); + self.list.extend(self.visible_uncached.iter()); + self.list.extend(self.visible_cached.iter()); + } + } + + /// Move deferred interest-ring tiles onto the pending list. + /// Returns true when there is interest work left to do. + pub fn promote_deferred_interest(&mut self) -> bool { + if self.deferred_interest.is_empty() { + return false; + } + self.list.append(&mut self.deferred_interest); + true } pub fn pop(&mut self) -> Option { self.list.pop() } } + +pub fn join_nonempty(mut acc: skia::Rect, rect: skia::Rect) -> skia::Rect { + if rect.is_empty() { + return acc; + } + if acc.is_empty() { + rect + } else { + acc.join(rect); + acc + } +} + +/// old ∪ new ∪ indexed tile coverage for post-edit cache eviction. +pub fn union_edit_dirty_rect( + old: Option, + new: skia::Rect, + indexed: skia::Rect, +) -> skia::Rect { + [old, Some(new), Some(indexed)] + .into_iter() + .flatten() + .fold(skia::Rect::new_empty(), join_nonempty) +} + +#[cfg(test)] +mod tests { + use super::*; + use skia_safe as skia; + + #[test] + fn atlas_slot_is_full_size_when_tiles_fit() { + assert_eq!(tile_atlas_slot_size(64, 4096), 512); + assert_eq!(tile_atlas_slot_size(1, 4096), 512); + } + + #[test] + fn atlas_slot_shrinks_to_pack_interest_tiles() { + // 150 slots → 13×13 grid, 4096/13 = 315. + assert_eq!(tile_atlas_slot_size(150, 4096), 315); + let side = 4096 / 315; + assert!(side * side >= 150); + } + + #[test] + fn atlas_compose_scale_is_one_at_full_slot() { + assert_eq!(tile_atlas_compose_scale(512), 1.0); + } + + #[test] + fn atlas_compose_scale_keeps_dest_tile_size_when_packed() { + let slot = 315; + let scale = tile_atlas_compose_scale(slot); + let src = tile_atlas_compose_src_size(slot); + assert!((scale * src - TILE_SIZE).abs() < 1e-4); + assert!(src < slot as f32); + } + + #[test] + fn edit_dirty_rect_includes_pre_rotate_extent_outside_current_index() { + // Indexed tiles are interest-clipped; old AABB still covers wings. + let old = skia::Rect::from_ltrb(-1103.0, 1871.1, 4693.2, 3559.9); + let new = skia::Rect::from_ltrb(1445.0, -164.4, 2144.9, 5598.0); + let indexed = skia::Rect::from_ltrb(663.1, 1989.4, 2652.6, 3315.7); + let left_wing = skia::Rect::from_ltrb(-3926.0, 0.0, 0.0, 3926.0); + let right_wing = skia::Rect::from_ltrb(3926.0, 0.0, 7852.0, 3926.0); + + let without_old = union_edit_dirty_rect(None, new, indexed); + assert!(!without_old.intersects(left_wing)); + assert!(!without_old.intersects(right_wing)); + + let dirty = union_edit_dirty_rect(Some(old), new, indexed); + assert!(dirty.intersects(left_wing)); + assert!(dirty.intersects(right_wing)); + } +} diff --git a/render-wasm/src/utils.rs b/render-wasm/src/utils.rs index 2301846c10..12c9c51f90 100644 --- a/render-wasm/src/utils.rs +++ b/render-wasm/src/utils.rs @@ -34,7 +34,20 @@ pub fn get_fallback_fonts() -> &'static HashSet { } pub fn get_font_collection() -> &'static FontCollection { - with_state!(state, { state.font_collection() }) + if crate::globals::has_render_resources() { + get_resources().fonts.font_collection() + } else { + with_state!(state, { state.font_collection() }) + } +} + +/// A negative f32 means "unset" — the renderer falls back to its default. +pub fn decode_optional_f32(value: f32) -> Option { + if value.is_finite() && value >= 0.0 { + Some(value) + } else { + None + } } #[derive(Debug, Clone, Copy)] diff --git a/render-wasm/src/view.rs b/render-wasm/src/view.rs index efa5394012..3463db6a8f 100644 --- a/render-wasm/src/view.rs +++ b/render-wasm/src/view.rs @@ -94,3 +94,59 @@ impl Viewbox { matrix } } + +/// Scale `dpr` down so `floor(css * dpr)` fits in `max_dim` on both axes. +/// Used when a large viewport combined with a high DPR would exceed the GPU +/// (or our surface cap) on either axis. +pub fn clamp_dpr_for_surface(css_w: f32, css_h: f32, dpr: f32, max_dim: i32) -> f32 { + let css_w = css_w.max(1.0); + let css_h = css_h.max(1.0); + let dpr = dpr.max(0.0); + let max_dim = max_dim.max(1) as f32; + let raw_w = (css_w * dpr).floor().max(1.0); + let raw_h = (css_h * dpr).floor().max(1.0); + let scale = (max_dim / raw_w).min(max_dim / raw_h).min(1.0); + dpr * scale +} + +#[cfg(test)] +mod tests { + use super::clamp_dpr_for_surface; + + #[test] + fn clamp_dpr_keeps_hidpi_viewport_under_cap() { + let dpr = clamp_dpr_for_surface(2560.0, 1440.0, 2.0, 8192); + assert!((dpr - 2.0).abs() < 1e-5); + assert!((2560.0 * dpr).floor() <= 8192.0); + } + + #[test] + fn clamp_dpr_caps_very_large_viewport_at_dpr2() { + // 10240×5760 CSS at DPR 2 → 20480 px unclamped on the long edge. + let dpr = clamp_dpr_for_surface(10240.0, 5760.0, 2.0, 8192); + assert!((10240.0 * dpr).floor() <= 8192.0); + assert!((5760.0 * dpr).floor() <= 8192.0); + assert!(dpr < 2.0); + } + + #[test] + fn clamp_dpr_caps_large_viewport_at_dpr2() { + let dpr = clamp_dpr_for_surface(5120.0, 2880.0, 2.0, 8192); + assert!((5120.0 * dpr).floor() <= 8192.0); + assert!(dpr < 2.0); + assert!(dpr > 1.0); + } + + #[test] + fn clamp_dpr_physical_size_is_floor_of_css_times_dpr() { + let css_w = 5120.0; + let css_h = 2880.0; + let dpr = clamp_dpr_for_surface(css_w, css_h, 2.0, 8192); + let phys_w = (css_w * dpr).floor(); + let phys_h = (css_h * dpr).floor(); + assert!(phys_w <= 8192.0); + assert!(phys_h <= 8192.0); + assert!((css_w * dpr - phys_w).abs() < 1.0); + assert!((css_h * dpr - phys_h).abs() < 1.0); + } +} diff --git a/render-wasm/src/wasm/fills.rs b/render-wasm/src/wasm/fills.rs index c0a8d6850d..c4084ea983 100644 --- a/render-wasm/src/wasm/fills.rs +++ b/render-wasm/src/wasm/fills.rs @@ -8,7 +8,7 @@ mod gradient; mod image; mod solid; -const RAW_FILL_DATA_SIZE: usize = std::mem::size_of::(); +pub(crate) const RAW_FILL_DATA_SIZE: usize = std::mem::size_of::(); #[repr(C, u8, align(4))] #[derive(Debug, PartialEq, Clone, Copy, ToJs)] diff --git a/render-wasm/src/wasm/fonts.rs b/render-wasm/src/wasm/fonts.rs index 112d523035..f3ad1d4189 100644 --- a/render-wasm/src/wasm/fonts.rs +++ b/render-wasm/src/wasm/fonts.rs @@ -1,5 +1,6 @@ use macros::{wasm_error, ToJs}; +use crate::error::Error; use crate::get_resources; use crate::mem; use crate::render::FontStore; @@ -55,6 +56,29 @@ pub extern "C" fn store_font( Ok(()) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn store_font_url( + a: u32, + b: u32, + c: u32, + d: u32, + weight: u32, + style: u8, +) -> Result<()> { + let id = uuid_from_u32_quartet(a, b, c, d); + let url_bytes = mem::bytes(); + let url = String::from_utf8(url_bytes) + .map_err(|_| Error::CriticalError("Invalid UTF-8 in font source URL".to_string()))?; + mem::free_bytes()?; + + let font_style = RawFontStyle::from(style); + let family = FontFamily::new(id, weight, font_style.into()); + get_resources().fonts.set_source_url(&family.alias(), url); + + Ok(()) +} + /// Resets the font store to its default state, dropping every font uploaded via /// `store_font`. A headless host that reuses a single WASM instance across /// requests must call this per render so fonts don't accumulate unbounded. diff --git a/render-wasm/src/wasm/layouts.rs b/render-wasm/src/wasm/layouts.rs index d179188e51..364ef49cbd 100644 --- a/render-wasm/src/wasm/layouts.rs +++ b/render-wasm/src/wasm/layouts.rs @@ -7,6 +7,9 @@ pub mod constraints; mod flex; mod grid; +pub use align::{RawAlignContent, RawAlignItems, RawAlignSelf, RawJustifyContent, RawJustifyItems}; +pub use flex::{RawFlexDirection, RawWrapType}; + #[derive(Debug, Clone, PartialEq, Copy, ToJs)] #[repr(u8)] #[allow(dead_code)] diff --git a/render-wasm/src/wasm/layouts/constraints.rs b/render-wasm/src/wasm/layouts/constraints.rs index 8760ed5592..5e87e61f70 100644 --- a/render-wasm/src/wasm/layouts/constraints.rs +++ b/render-wasm/src/wasm/layouts/constraints.rs @@ -7,11 +7,12 @@ use crate::with_current_shape_mut; #[repr(u8)] #[allow(dead_code)] pub enum RawConstraintH { - Left = 0, - Right = 1, - Leftright = 2, // odd casing to comply with cljs value - Center = 3, - Scale = 4, + None = 0, + Left = 1, + Right = 2, + Leftright = 3, // odd casing to comply with cljs value + Center = 4, + Scale = 5, } impl From for RawConstraintH { @@ -20,14 +21,15 @@ impl From for RawConstraintH { } } -impl From for ConstraintH { +impl From for Option { fn from(value: RawConstraintH) -> Self { match value { - RawConstraintH::Left => ConstraintH::Left, - RawConstraintH::Right => ConstraintH::Right, - RawConstraintH::Leftright => ConstraintH::LeftRight, - RawConstraintH::Center => ConstraintH::Center, - RawConstraintH::Scale => ConstraintH::Scale, + RawConstraintH::None => None, + RawConstraintH::Left => Some(ConstraintH::Left), + RawConstraintH::Right => Some(ConstraintH::Right), + RawConstraintH::Leftright => Some(ConstraintH::LeftRight), + RawConstraintH::Center => Some(ConstraintH::Center), + RawConstraintH::Scale => Some(ConstraintH::Scale), } } } @@ -36,11 +38,12 @@ impl From for ConstraintH { #[repr(u8)] #[allow(dead_code)] pub enum RawConstraintV { - Top = 0, - Bottom = 1, - Topbottom = 2, // odd casing to comply with cljs value - Center = 3, - Scale = 4, + None = 0, + Top = 1, + Bottom = 2, + Topbottom = 3, // odd casing to comply with cljs value + Center = 4, + Scale = 5, } impl From for RawConstraintV { @@ -49,14 +52,15 @@ impl From for RawConstraintV { } } -impl From for ConstraintV { +impl From for Option { fn from(value: RawConstraintV) -> Self { match value { - RawConstraintV::Top => ConstraintV::Top, - RawConstraintV::Bottom => ConstraintV::Bottom, - RawConstraintV::Topbottom => ConstraintV::TopBottom, - RawConstraintV::Center => ConstraintV::Center, - RawConstraintV::Scale => ConstraintV::Scale, + RawConstraintV::None => None, + RawConstraintV::Top => Some(ConstraintV::Top), + RawConstraintV::Bottom => Some(ConstraintV::Bottom), + RawConstraintV::Topbottom => Some(ConstraintV::TopBottom), + RawConstraintV::Center => Some(ConstraintV::Center), + RawConstraintV::Scale => Some(ConstraintV::Scale), } } } @@ -64,16 +68,16 @@ impl From for ConstraintV { #[no_mangle] pub extern "C" fn set_shape_constraint_h(constraint: u8) { with_current_shape_mut!(state, |shape: &mut Shape| { - let constraint = RawConstraintH::from(constraint); - shape.set_constraint_h(Some(constraint.into())); + let constraint: Option = RawConstraintH::from(constraint).into(); + shape.set_constraint_h(constraint); }); } #[no_mangle] pub extern "C" fn set_shape_constraint_v(constraint: u8) { with_current_shape_mut!(state, |shape: &mut Shape| { - let constraint = RawConstraintV::from(constraint); - shape.set_constraint_v(Some(constraint.into())); + let constraint: Option = RawConstraintV::from(constraint).into(); + shape.set_constraint_v(constraint); }); } diff --git a/render-wasm/src/wasm/shapes/base_props.rs b/render-wasm/src/wasm/shapes/base_props.rs index e9b6a6e7b0..9ab65f78fe 100644 --- a/render-wasm/src/wasm/shapes/base_props.rs +++ b/render-wasm/src/wasm/shapes/base_props.rs @@ -1,4 +1,3 @@ -use crate::mem; use crate::shapes::{BlendMode, ConstraintH, ConstraintV}; use crate::utils::uuid_from_u32_quartet; use crate::uuid::Uuid; @@ -6,17 +5,14 @@ use crate::wasm::blend::RawBlendMode; use crate::wasm::layouts::constraints::{RawConstraintH, RawConstraintV}; use crate::with_state; -#[allow(unused_imports)] -use crate::error::{Error, Result}; -use macros::wasm_error; +use crate::error::Result; use super::RawShapeType; const FLAG_CLIP_CONTENT: u8 = 0b0000_0001; const FLAG_HIDDEN: u8 = 0b0000_0010; -const CONSTRAINT_NONE: u8 = 0xFF; -const RAW_BASE_PROPS_SIZE: usize = std::mem::size_of::(); +pub(crate) const RAW_BASE_PROPS_SIZE: usize = std::mem::size_of::(); /// Binary layout for batched shape base properties. /// @@ -25,7 +21,7 @@ const RAW_BASE_PROPS_SIZE: usize = std::mem::size_of::(); #[repr(C)] #[repr(align(4))] #[derive(Debug, Clone, Copy)] -pub struct RawBasePropsData { +pub(crate) struct RawBasePropsData { // UUID id (16 bytes) id_a: u32, id_b: u32, @@ -87,19 +83,11 @@ impl RawBasePropsData { } fn constraint_h(&self) -> Option { - if self.constraint_h == CONSTRAINT_NONE { - None - } else { - Some(RawConstraintH::from(self.constraint_h).into()) - } + RawConstraintH::from(self.constraint_h).into() } fn constraint_v(&self) -> Option { - if self.constraint_v == CONSTRAINT_NONE { - None - } else { - Some(RawConstraintV::from(self.constraint_v).into()) - } + RawConstraintV::from(self.constraint_v).into() } } @@ -109,21 +97,8 @@ impl From<[u8; RAW_BASE_PROPS_SIZE]> for RawBasePropsData { } } -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_shape_base_props() -> Result<()> { - let bytes = mem::bytes(); - - if bytes.len() < RAW_BASE_PROPS_SIZE { - return Ok(()); - } - - // FIXME: this should just be a try_from - let data: [u8; RAW_BASE_PROPS_SIZE] = bytes[..RAW_BASE_PROPS_SIZE] - .try_into() - .map_err(|_| Error::CriticalError("Invalid bytes for base props".to_string()))?; - let raw = RawBasePropsData::from(data); - +/// Apply base props from a parsed record (selects the shape and sets core attrs). +pub(crate) fn apply_base_props(raw: &RawBasePropsData) -> Result<()> { let id = raw.id(); let parent_id = raw.parent_id(); let shape_type = RawShapeType::from(raw.shape_type); @@ -219,10 +194,10 @@ mod tests { bytes[33] = FLAG_CLIP_CONTENT | FLAG_HIDDEN; // blend_mode = Overlay (15) bytes[34] = 15; - // constraint_h = Center (3) - bytes[35] = 3; - // constraint_v = Scale (4) - bytes[36] = 4; + // constraint_h = Center (4) + bytes[35] = 4; + // constraint_v = Scale (5) + bytes[36] = 5; // opacity bytes[40..44].copy_from_slice(&0.5_f32.to_le_bytes()); // rotation diff --git a/render-wasm/src/wasm/shapes/mod.rs b/render-wasm/src/wasm/shapes/mod.rs index 901feb57f8..11175d3780 100644 --- a/render-wasm/src/wasm/shapes/mod.rs +++ b/render-wasm/src/wasm/shapes/mod.rs @@ -1,4 +1,5 @@ mod base_props; +mod upload_batch; use macros::ToJs; diff --git a/render-wasm/src/wasm/shapes/upload_batch.rs b/render-wasm/src/wasm/shapes/upload_batch.rs new file mode 100644 index 0000000000..99051842a4 --- /dev/null +++ b/render-wasm/src/wasm/shapes/upload_batch.rs @@ -0,0 +1,473 @@ +//! Multi-shape / enlarged cold-upload batch protocol. +//! +//! Buffer layout: +//! ```text +//! [u32 shape_count] +//! repeat shape_count times: +//! [u32 payload_len] // bytes after this u32 +//! [104 base props] // RawBasePropsData +//! [u32 section_mask] +//! optional sections (presence via mask; order is FIXED, not bit-numeric): +//! CHILDREN: [u32 n][n × 16 uuid] +//! BLUR_LAYER: [u8 hidden][u8;3 pad][f32 value] +//! BLUR_BG: same +//! SHADOWS: [u32 n][n × 24] +//! MASKED: [u8 value][u8;3 pad] +//! BOOL_TYPE: [u8 value][u8;3 pad] +//! GROW_TYPE: [u8 value][u8;3 pad] +//! FLEX: 32 bytes (clears container layout, then sets flex) +//! LAYOUT_ITEM: 40 bytes (must follow FLEX so clear_layout does not wipe it) +//! FILLS: [u8 n][u8;3][n × RawFillData] (same as set_shape_fills) +//! STROKES: [u32 n][n × (36-byte header + RawFillData)] +//! ``` +//! +//! Text, path geometry, and grid tracks/cells stay on the legacy +//! per-shape FFI path after the batch flush. + +use skia_safe as skia; + +use crate::mem; +use crate::shapes::{Blur, BlurType, Shadow, ShadowStyle, Stroke, Type}; +use crate::utils::{decode_optional_f32, uuid_from_u32_quartet}; +use crate::uuid::Uuid; +use crate::wasm::fills::{read_fills_from_bytes, RawFillData, RAW_FILL_DATA_SIZE}; +use crate::wasm::layouts::{ + RawAlignContent, RawAlignItems, RawAlignSelf, RawFlexDirection, RawJustifyContent, + RawJustifyItems, RawSizing, RawWrapType, +}; +use crate::wasm::paths::bools::RawBoolType; +use crate::wasm::shadows::RawShadowStyle; +use crate::wasm::shapes::base_props::{apply_base_props, RawBasePropsData, RAW_BASE_PROPS_SIZE}; +use crate::wasm::strokes::{RawStrokeCap, RawStrokeStyle}; +use crate::wasm::text::RawGrowType; +use crate::with_current_shape_mut; +use crate::with_state; + +#[allow(unused_imports)] +use crate::error::{Error, Result}; +use macros::wasm_error; + +const SECTION_CHILDREN: u32 = 1 << 0; +const SECTION_BLUR_LAYER: u32 = 1 << 1; +const SECTION_BLUR_BG: u32 = 1 << 2; +const SECTION_SHADOWS: u32 = 1 << 3; +const SECTION_MASKED: u32 = 1 << 4; +const SECTION_BOOL_TYPE: u32 = 1 << 5; +const SECTION_GROW_TYPE: u32 = 1 << 6; +const SECTION_LAYOUT_ITEM: u32 = 1 << 7; +const SECTION_FLEX: u32 = 1 << 8; +const SECTION_FILLS: u32 = 1 << 9; +const SECTION_STROKES: u32 = 1 << 10; + +const STROKE_ALIGN_INNER: u8 = 1; +const STROKE_ALIGN_OUTER: u8 = 2; + +struct Cursor<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Cursor<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + + fn remaining(&self) -> usize { + self.data.len().saturating_sub(self.pos) + } + + fn take(&mut self, n: usize) -> Result<&'a [u8]> { + if self.remaining() < n { + return Err(Error::CriticalError( + "upload_batch: truncated buffer".to_string(), + )); + } + let slice = &self.data[self.pos..self.pos + n]; + self.pos += n; + Ok(slice) + } + + fn u32(&mut self) -> Result { + let b = self.take(4)?; + Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + fn f32(&mut self) -> Result { + let b = self.take(4)?; + Ok(f32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + + fn i32(&mut self) -> Result { + let b = self.take(4)?; + Ok(i32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + + fn uuid(&mut self) -> Result { + let a = self.u32()?; + let b = self.u32()?; + let c = self.u32()?; + let d = self.u32()?; + Ok(uuid_from_u32_quartet(a, b, c, d)) + } +} + +fn read_base_props(cur: &mut Cursor<'_>) -> Result { + let bytes = cur.take(RAW_BASE_PROPS_SIZE)?; + let arr: [u8; RAW_BASE_PROPS_SIZE] = bytes + .try_into() + .map_err(|_| Error::CriticalError("upload_batch: bad base props".to_string()))?; + Ok(RawBasePropsData::from(arr)) +} + +fn apply_blur(layer: bool, hidden: bool, value: f32) { + with_current_shape_mut!(state, |shape: &mut Shape| { + let blur_type = if layer { + BlurType::LayerBlur + } else { + BlurType::BackgroundBlur + }; + let blur = Some(Blur::new(blur_type, hidden, value)); + if layer { + shape.set_blur(blur); + } else { + shape.set_background_blur(blur); + } + }); +} + +fn clear_blur(layer: bool) { + with_current_shape_mut!(state, |shape: &mut Shape| { + if layer { + shape.set_blur(None); + } else { + shape.set_background_blur(None); + } + }); +} + +fn apply_shadows(cur: &mut Cursor<'_>) -> Result<()> { + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.clear_shadows(); + }); + let n = cur.u32()? as usize; + for _ in 0..n { + let rgba = cur.u32()?; + let blur = cur.f32()?; + let spread = cur.f32()?; + let x = cur.f32()?; + let y = cur.f32()?; + let style = cur.u8()?; + let hidden = cur.u8()? != 0; + let _pad = cur.take(2)?; + with_current_shape_mut!(state, |shape: &mut Shape| { + let color = skia::Color::new(rgba); + let style: ShadowStyle = RawShadowStyle::from(style).into(); + shape.add_shadow(Shadow::new(color, blur, spread, (x, y), style, hidden)); + }); + } + Ok(()) +} + +fn apply_layout_item(cur: &mut Cursor<'_>) -> Result<()> { + let margin_top = cur.f32()?; + let margin_right = cur.f32()?; + let margin_bottom = cur.f32()?; + let margin_left = cur.f32()?; + let h_sizing = cur.u8()?; + let v_sizing = cur.u8()?; + let flags = cur.u8()?; + let align_self = cur.u8()?; + let max_h = cur.f32()?; + let min_h = cur.f32()?; + let max_w = cur.f32()?; + let min_w = cur.f32()?; + let z_index = cur.i32()?; + + let has_max_h = (flags & 0x01) != 0; + let has_min_h = (flags & 0x02) != 0; + let has_max_w = (flags & 0x04) != 0; + let has_min_w = (flags & 0x08) != 0; + let is_absolute = (flags & 0x10) != 0; + + let h_sizing = RawSizing::from(h_sizing); + let v_sizing = RawSizing::from(v_sizing); + let max_h = has_max_h.then(|| max_h.max(0.01)); + let min_h = has_min_h.then(|| min_h.clamp(0.01, max_h.unwrap_or(f32::INFINITY))); + let max_w = has_max_w.then(|| max_w.max(0.01)); + let min_w = has_min_w.then(|| min_w.clamp(0.01, max_w.unwrap_or(f32::INFINITY))); + let z_index = if z_index != 0 { Some(z_index) } else { None }; + let align_self = RawAlignSelf::from(align_self).try_into().ok(); + + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.set_flex_layout_child_data( + margin_top, + margin_right, + margin_bottom, + margin_left, + h_sizing.into(), + v_sizing.into(), + max_h, + min_h, + max_w, + min_w, + align_self, + is_absolute, + z_index, + ); + }); + Ok(()) +} + +fn apply_flex(cur: &mut Cursor<'_>) -> Result<()> { + let dir = cur.u8()?; + let align_items = cur.u8()?; + let align_content = cur.u8()?; + let justify_items = cur.u8()?; + let justify_content = cur.u8()?; + let wrap_type = cur.u8()?; + let _pad = cur.take(2)?; + let row_gap = cur.f32()?; + let column_gap = cur.f32()?; + let padding_top = cur.f32()?; + let padding_right = cur.f32()?; + let padding_bottom = cur.f32()?; + let padding_left = cur.f32()?; + + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.clear_layout(); + shape.set_flex_layout_data( + RawFlexDirection::from(dir).into(), + row_gap, + column_gap, + RawAlignItems::from(align_items).into(), + RawAlignContent::from(align_content).into(), + RawJustifyItems::from(justify_items).into(), + RawJustifyContent::from(justify_content).into(), + RawWrapType::from(wrap_type).into(), + padding_top, + padding_right, + padding_bottom, + padding_left, + ); + }); + Ok(()) +} + +fn apply_shape_payload(payload: &[u8]) -> Result<()> { + let mut cur = Cursor::new(payload); + let raw = read_base_props(&mut cur)?; + let mask = cur.u32()?; + apply_base_props(&raw)?; + + if mask & SECTION_CHILDREN != 0 { + let n = cur.u32()? as usize; + let mut entries = Vec::with_capacity(n); + for _ in 0..n { + entries.push(cur.uuid()?); + } + with_state!(state, { + state.set_current_shape_children(entries)?; + }); + } + + if mask & SECTION_BLUR_LAYER != 0 { + let hidden = cur.u8()? != 0; + let _ = cur.take(3)?; + let value = cur.f32()?; + apply_blur(true, hidden, value); + } else { + clear_blur(true); + } + + if mask & SECTION_BLUR_BG != 0 { + let hidden = cur.u8()? != 0; + let _ = cur.take(3)?; + let value = cur.f32()?; + apply_blur(false, hidden, value); + } else { + clear_blur(false); + } + + if mask & SECTION_SHADOWS != 0 { + apply_shadows(&mut cur)?; + } else { + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.clear_shadows(); + }); + } + + if mask & SECTION_MASKED != 0 { + let masked = cur.u8()? != 0; + let _ = cur.take(3)?; + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.set_masked(masked); + }); + } + + if mask & SECTION_BOOL_TYPE != 0 { + let raw_bool = cur.u8()?; + let _ = cur.take(3)?; + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.set_bool_type(RawBoolType::from(raw_bool).into()); + }); + } + + if mask & SECTION_GROW_TYPE != 0 { + let raw_grow = cur.u8()?; + let _ = cur.take(3)?; + with_current_shape_mut!(state, |shape: &mut Shape| { + if let Type::Text(text_content) = &mut shape.shape_type { + text_content.set_grow_type(RawGrowType::from(raw_grow).into()); + } + }); + } + + // FLEX before LAYOUT_ITEM: clear_layout must not wipe the item we just set. + // Only clear when this payload owns layout (workspace cold-load). Exporter / + // serialize-shape! omit both bits and must not clobber existing layout. + if mask & SECTION_FLEX != 0 { + apply_flex(&mut cur)?; + } else if mask & SECTION_LAYOUT_ITEM != 0 { + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.clear_layout(); + }); + } + + if mask & SECTION_LAYOUT_ITEM != 0 { + apply_layout_item(&mut cur)?; + } + + let is_text_shape = with_state!(state, { + state + .current_shape() + .is_some_and(|shape| matches!(shape.shape_type, Type::Text(_))) + }); + + if mask & SECTION_FILLS != 0 { + let fills = parse_fills(&mut cur)?; + if is_text_shape { + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.set_deferred_batch_fills(fills); + }); + } else { + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.set_fills(fills); + }); + } + } + + if mask & SECTION_STROKES != 0 { + let strokes = parse_strokes(&mut cur)?; + if is_text_shape { + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.set_deferred_batch_strokes(strokes); + }); + } else { + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.clear_strokes(); + for stroke in strokes { + shape.add_stroke(stroke); + } + }); + } + } + + Ok(()) +} + +fn parse_fills(cur: &mut Cursor<'_>) -> Result> { + let header = cur.take(4)?; + let n = header[0] as usize; + let bytes = if n == 0 { + &[][..] + } else { + cur.take(n * RAW_FILL_DATA_SIZE)? + }; + Ok(read_fills_from_bytes(bytes, n)) +} + +fn parse_strokes(cur: &mut Cursor<'_>) -> Result> { + let n = cur.u32()? as usize; + let mut strokes = Vec::with_capacity(n); + for _ in 0..n { + let width = cur.f32()?; + let style = cur.u8()?; + let align = cur.u8()?; + let cap_start = cur.u8()?; + let cap_end = cur.u8()?; + let dash = cur.f32()?; + let gap = cur.f32()?; + let has_sides = cur.u8()? != 0; + let _pad = cur.take(3)?; + let top = cur.f32()?; + let right = cur.f32()?; + let bottom = cur.f32()?; + let left = cur.f32()?; + let fill_bytes = cur.take(RAW_FILL_DATA_SIZE)?; + let fill = RawFillData::try_from(fill_bytes) + .map_err(|e| Error::CriticalError(format!("upload_batch stroke fill: {e}")))?; + + let stroke_style = RawStrokeStyle::from(style); + let cap_start = RawStrokeCap::from(cap_start); + let cap_end = RawStrokeCap::from(cap_end); + let dash = decode_optional_f32(dash); + let gap = decode_optional_f32(gap); + + let mut stroke = match align { + STROKE_ALIGN_INNER => Stroke::new_inner_stroke( + width, + stroke_style.into(), + cap_start.try_into().ok(), + cap_end.try_into().ok(), + dash, + gap, + ), + STROKE_ALIGN_OUTER => Stroke::new_outer_stroke( + width, + stroke_style.into(), + cap_start.try_into().ok(), + cap_end.try_into().ok(), + dash, + gap, + ), + _ => Stroke::new_center_stroke( + width, + stroke_style.into(), + cap_start.try_into().ok(), + cap_end.try_into().ok(), + dash, + gap, + ), + }; + if has_sides { + stroke.widths = Some([top, right, bottom, left]); + } + stroke.fill = fill.into(); + strokes.push(stroke); + } + Ok(strokes) +} + +/// Apply a multi-shape upload buffer previously written via `_alloc_bytes`. +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_shapes_batch() -> Result<()> { + let bytes = mem::bytes(); + if bytes.len() < 4 { + return Ok(()); + } + + let mut cur = Cursor::new(&bytes); + let count = cur.u32()? as usize; + for _ in 0..count { + let payload_len = cur.u32()? as usize; + let payload = cur.take(payload_len)?; + apply_shape_payload(payload)?; + } + + Ok(()) +} diff --git a/render-wasm/src/wasm/strokes.rs b/render-wasm/src/wasm/strokes.rs index 647510ff0b..02f9bf1d6e 100644 --- a/render-wasm/src/wasm/strokes.rs +++ b/render-wasm/src/wasm/strokes.rs @@ -2,6 +2,7 @@ use macros::ToJs; use crate::mem; use crate::shapes::{self, StrokeCap, StrokeStyle}; +use crate::utils::decode_optional_f32; use crate::with_current_shape_mut; #[derive(Debug, Clone, PartialEq, Copy, ToJs)] @@ -68,17 +69,6 @@ impl TryFrom for StrokeCap { } } -// A negative value means "unset" — the renderer falls back to its default -// dash pattern. We use a sentinel instead of passing a bool because adding -// two f32 params keeps the FFI signature flat and allocation-free. -fn decode_optional(value: f32) -> Option { - if value.is_finite() && value >= 0.0 { - Some(value) - } else { - None - } -} - #[no_mangle] pub extern "C" fn add_shape_center_stroke( width: f32, @@ -98,8 +88,8 @@ pub extern "C" fn add_shape_center_stroke( stroke_style.into(), cap_start.try_into().ok(), cap_end.try_into().ok(), - decode_optional(dash), - decode_optional(gap), + decode_optional_f32(dash), + decode_optional_f32(gap), )); }); } @@ -123,8 +113,8 @@ pub extern "C" fn add_shape_inner_stroke( stroke_style.into(), cap_start.try_into().ok(), cap_end.try_into().ok(), - decode_optional(dash), - decode_optional(gap), + decode_optional_f32(dash), + decode_optional_f32(gap), )); }); } @@ -148,8 +138,8 @@ pub extern "C" fn add_shape_outer_stroke( stroke_style.into(), cap_start.try_into().ok(), cap_end.try_into().ok(), - decode_optional(dash), - decode_optional(gap), + decode_optional_f32(dash), + decode_optional_f32(gap), )); }); } diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index 6e2575741f..c154afbf89 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -376,6 +376,7 @@ fn update_text_layout(shape: &mut Shape, force: bool) { text_content.force_next_layout_update(); } text_content.update_layout(shape.selrect); + shape.apply_deferred_batch_paint(); shape.invalidate_extrect(); } } diff --git a/render-wasm/src/wasm/text/helpers.rs b/render-wasm/src/wasm/text/helpers.rs index 32ed8238d7..95bec9f9a6 100644 --- a/render-wasm/src/wasm/text/helpers.rs +++ b/render-wasm/src/wasm/text/helpers.rs @@ -29,14 +29,14 @@ pub fn clamp_cursor( paragraphs: &[Paragraph], ) -> TextPositionWithAffinity { if paragraphs.is_empty() { - return TextPositionWithAffinity::new_without_affinity(0, 0); + return TextPositionWithAffinity::new_downstream_affinity(0, 0); } let para_idx = position.paragraph.min(paragraphs.len() - 1); let para_len = paragraph_char_count(¶graphs[para_idx]); let char_offset = position.offset.min(para_len); - TextPositionWithAffinity::new_without_affinity(para_idx, char_offset) + TextPositionWithAffinity::new_downstream_affinity(para_idx, char_offset) } /// Move cursor left by one character. @@ -47,7 +47,7 @@ pub fn move_cursor_backward( ) -> TextPositionWithAffinity { if !word_boundary { if cursor.offset > 0 { - return TextPositionWithAffinity::new_without_affinity( + return TextPositionWithAffinity::new_downstream_affinity( cursor.paragraph, cursor.offset - 1, ); @@ -55,7 +55,7 @@ pub fn move_cursor_backward( if cursor.paragraph > 0 { let prev_para = cursor.paragraph - 1; let char_count = paragraph_char_count(¶graphs[prev_para]); - return TextPositionWithAffinity::new_without_affinity(prev_para, char_count); + return TextPositionWithAffinity::new_downstream_affinity(prev_para, char_count); } return *cursor; } @@ -111,7 +111,7 @@ pub fn move_cursor_backward( } } - TextPositionWithAffinity::new_without_affinity(para_idx, offset) + TextPositionWithAffinity::new_downstream_affinity(para_idx, offset) } /// Move cursor right by one character. @@ -124,13 +124,13 @@ pub fn move_cursor_forward( let para = ¶graphs[cursor.paragraph]; let char_count = paragraph_char_count(para); if cursor.offset < char_count { - return TextPositionWithAffinity::new_without_affinity( + return TextPositionWithAffinity::new_downstream_affinity( cursor.paragraph, cursor.offset + 1, ); } if cursor.paragraph < paragraphs.len() - 1 { - return TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0); + return TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph + 1, 0); } return *cursor; } @@ -185,7 +185,7 @@ pub fn move_cursor_forward( } } - TextPositionWithAffinity::new_without_affinity(para_idx, offset) + TextPositionWithAffinity::new_downstream_affinity(para_idx, offset) } /// Move cursor up by one line. @@ -203,9 +203,9 @@ pub fn move_cursor_up( let prev_para = cursor.paragraph - 1; let char_count = paragraph_char_count(¶graphs[prev_para]); let new_offset = cursor.offset.min(char_count); - TextPositionWithAffinity::new_without_affinity(prev_para, new_offset) + TextPositionWithAffinity::new_downstream_affinity(prev_para, new_offset) } else { - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0) + TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph, 0) } } @@ -224,10 +224,10 @@ pub fn move_cursor_down( let next_para = cursor.paragraph + 1; let char_count = paragraph_char_count(¶graphs[next_para]); let new_offset = cursor.offset.min(char_count); - TextPositionWithAffinity::new_without_affinity(next_para, new_offset) + TextPositionWithAffinity::new_downstream_affinity(next_para, new_offset) } else { let char_count = paragraph_char_count(¶graphs[cursor.paragraph]); - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count) + TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph, char_count) } } @@ -237,7 +237,7 @@ pub fn move_cursor_line_start( _paragraphs: &[Paragraph], ) -> TextPositionWithAffinity { // TODO: Implement proper line-start using line metrics - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0) + TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph, 0) } /// Move cursor to end of current line. @@ -247,7 +247,7 @@ pub fn move_cursor_line_end( ) -> TextPositionWithAffinity { // TODO: Implement proper line-end using line metrics let char_count = paragraph_char_count(¶graphs[cursor.paragraph]); - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count) + TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph, char_count) } pub fn is_word_char(c: char) -> bool { @@ -299,7 +299,7 @@ pub fn replace_text_with_newlines( if let Some(new_offset) = replace_text_at_cursor(text_content, ¤t_cursor, lines[0]) { current_cursor = - TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph, new_offset); + TextPositionWithAffinity::new_downstream_affinity(current_cursor.paragraph, new_offset); } else { return None; } @@ -309,9 +309,9 @@ pub fn replace_text_with_newlines( break; } current_cursor = - TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph + 1, 0); + TextPositionWithAffinity::new_downstream_affinity(current_cursor.paragraph + 1, 0); if let Some(new_offset) = replace_text_at_cursor(text_content, ¤t_cursor, line) { - current_cursor = TextPositionWithAffinity::new_without_affinity( + current_cursor = TextPositionWithAffinity::new_downstream_affinity( current_cursor.paragraph, new_offset, ); @@ -338,7 +338,7 @@ pub fn insert_text_with_newlines( if let Some(new_offset) = insert_text_at_cursor(text_content, ¤t_cursor, lines[0]) { current_cursor = - TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph, new_offset); + TextPositionWithAffinity::new_downstream_affinity(current_cursor.paragraph, new_offset); } else { return None; } @@ -348,9 +348,9 @@ pub fn insert_text_with_newlines( break; } current_cursor = - TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph + 1, 0); + TextPositionWithAffinity::new_downstream_affinity(current_cursor.paragraph + 1, 0); if let Some(new_offset) = insert_text_at_cursor(text_content, ¤t_cursor, line) { - current_cursor = TextPositionWithAffinity::new_without_affinity( + current_cursor = TextPositionWithAffinity::new_downstream_affinity( current_cursor.paragraph, new_offset, ); @@ -459,11 +459,13 @@ pub fn delete_selection_range(text_content: &mut TextContent, selection: &TextSe let end = selection.end(); let paragraphs = text_content.paragraphs_mut(); - if start.paragraph >= paragraphs.len() { + if paragraphs.is_empty() || start.paragraph >= paragraphs.len() { return; } - if start.paragraph == end.paragraph { + let end_paragraph = end.paragraph.min(paragraphs.len() - 1); + + if start.paragraph == end_paragraph { delete_range_in_paragraph(&mut paragraphs[start.paragraph], start.offset, end.offset); } else { let start_para_len = paragraph_char_count(¶graphs[start.paragraph]); @@ -473,19 +475,15 @@ pub fn delete_selection_range(text_content: &mut TextContent, selection: &TextSe start_para_len, ); - delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.offset); + delete_range_in_paragraph(&mut paragraphs[end_paragraph], 0, end.offset); - if end.paragraph < paragraphs.len() { - let end_para_children: Vec<_> = - paragraphs[end.paragraph].children_mut().drain(..).collect(); - paragraphs[start.paragraph] - .children_mut() - .extend(end_para_children); - } + let end_para_children: Vec<_> = + paragraphs[end_paragraph].children_mut().drain(..).collect(); + paragraphs[start.paragraph] + .children_mut() + .extend(end_para_children); - if end.paragraph < paragraphs.len() { - paragraphs.drain((start.paragraph + 1)..=end.paragraph); - } + paragraphs.drain((start.paragraph + 1)..=end_paragraph); let children = paragraphs[start.paragraph].children_mut(); let has_content = children.iter().any(|span| !span.text.is_empty()); @@ -499,6 +497,11 @@ pub fn delete_selection_range(text_content: &mut TextContent, selection: &TextSe /// Delete a range of characters within a single paragraph. pub fn delete_range_in_paragraph(para: &mut Paragraph, start_offset: usize, end_offset: usize) { + // An out of bounds offset must not skip the deletion. + let para_len = paragraph_char_count(para); + let start_offset = start_offset.min(para_len); + let end_offset = end_offset.min(para_len); + if start_offset >= end_offset { return; } @@ -581,7 +584,7 @@ pub fn delete_char_before( let para = &mut paragraphs[cursor.paragraph]; let delete_pos = cursor.offset - 1; delete_range_in_paragraph(para, delete_pos, cursor.offset); - Some(TextPositionWithAffinity::new_without_affinity( + Some(TextPositionWithAffinity::new_downstream_affinity( cursor.paragraph, delete_pos, )) @@ -600,7 +603,7 @@ pub fn delete_char_before( paragraphs.remove(cursor.paragraph); - Some(TextPositionWithAffinity::new_without_affinity( + Some(TextPositionWithAffinity::new_downstream_affinity( prev_para_idx, prev_para_len, )) @@ -672,13 +675,13 @@ pub fn delete_word_before( } let selection = TextSelection { - anchor: TextPositionWithAffinity::new_without_affinity(start_paragraph, start_offset), - focus: TextPositionWithAffinity::new_without_affinity(end_paragraph, end_offset), + anchor: TextPositionWithAffinity::new_downstream_affinity(start_paragraph, start_offset), + focus: TextPositionWithAffinity::new_downstream_affinity(end_paragraph, end_offset), }; delete_selection_range(text_content, &selection); - Some(TextPositionWithAffinity::new_without_affinity( + Some(TextPositionWithAffinity::new_downstream_affinity( start_paragraph, start_offset, )) @@ -745,8 +748,8 @@ pub fn delete_word_after(text_content: &mut TextContent, cursor: &TextPositionWi } let selection = TextSelection { - anchor: TextPositionWithAffinity::new_without_affinity(start_paragraph, start_offset), - focus: TextPositionWithAffinity::new_without_affinity(end_paragraph, end_offset), + anchor: TextPositionWithAffinity::new_downstream_affinity(start_paragraph, start_offset), + focus: TextPositionWithAffinity::new_downstream_affinity(end_paragraph, end_offset), }; delete_selection_range(text_content, &selection); @@ -848,3 +851,124 @@ pub fn split_paragraph_at_cursor( true } + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::Rect; + use crate::shapes::{FontFamily, FontStyle, GrowType, TextAlign, TextSpan}; + use crate::uuid::Uuid; + + fn span(text: &str) -> TextSpan { + TextSpan::new( + text.to_string(), + FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), + 14.0, + 1.2, + 0.0, + None, + None, + TextDirection::LTR, + 400, + Uuid::nil(), + vec![], + ) + } + + fn paragraph(texts: &[&str]) -> Paragraph { + Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.2, + 0.0, + texts.iter().copied().map(span).collect(), + ) + } + + fn content(paragraphs: Vec) -> TextContent { + let mut content = + TextContent::new(Rect::from_xywh(0.0, 0.0, 100.0, 100.0), GrowType::Fixed); + for para in paragraphs { + content.add_paragraph(para); + } + content + } + + fn text_of(content: &TextContent) -> String { + content + .paragraphs() + .iter() + .map(|para| { + para.children() + .iter() + .map(|span| span.text.as_str()) + .collect::() + }) + .collect::>() + .join("\n") + } + + fn selection(start: (usize, usize), end: (usize, usize)) -> TextSelection { + TextSelection { + anchor: TextPositionWithAffinity::new_downstream_affinity(start.0, start.1), + focus: TextPositionWithAffinity::new_downstream_affinity(end.0, end.1), + } + } + + #[test] + fn delete_selection_range_deletes_a_whole_multibyte_paragraph() { + let mut content = content(vec![paragraph(&["Añadir"])]); + delete_selection_range(&mut content, &selection((0, 0), (0, 6))); + assert_eq!(text_of(&content), ""); + } + + #[test] + fn delete_range_in_paragraph_clamps_an_overshooting_end_offset() { + let mut para = paragraph(&["Añadir"]); + delete_range_in_paragraph(&mut para, 0, 7); + assert_eq!(para.children().len(), 1); + assert_eq!(para.children()[0].text, ""); + } + + #[test] + fn delete_selection_range_deletes_emoji() { + let mut content = content(vec![paragraph(&["Hi 😀"])]); + delete_selection_range(&mut content, &selection((0, 0), (0, 4))); + assert_eq!(text_of(&content), ""); + } + + #[test] + fn delete_selection_range_never_splits_an_emoji() { + let mut content = content(vec![paragraph(&["a😀b"])]); + delete_selection_range(&mut content, &selection((0, 1), (0, 2))); + assert_eq!(text_of(&content), "ab"); + } + + #[test] + fn delete_selection_range_spanning_several_spans() { + let mut content = content(vec![paragraph(&["Añ", "adir"])]); + delete_selection_range(&mut content, &selection((0, 1), (0, 4))); + assert_eq!(text_of(&content), "Air"); + } + + #[test] + fn delete_selection_range_across_paragraphs() { + let mut content = content(vec![ + paragraph(&["Añadir"]), + paragraph(&["Más"]), + paragraph(&["Fin"]), + ]); + delete_selection_range(&mut content, &selection((0, 0), (2, 3))); + assert_eq!(content.paragraphs().len(), 1); + assert_eq!(text_of(&content), ""); + } + + #[test] + fn delete_selection_range_clamps_an_out_of_range_focus() { + let mut content = content(vec![paragraph(&["Añadir"])]); + delete_selection_range(&mut content, &selection((0, 0), (5, 99))); + assert_eq!(text_of(&content), ""); + } +} diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index ca54edc39a..08e5c9bf54 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -1,12 +1,10 @@ use macros::{wasm_error, ToJs}; use crate::globals::{get_render_state, get_text_editor_state}; -use crate::math::{Matrix, Point, Rect}; +use crate::math::{Matrix, Point}; use crate::mem; -use crate::render::text_editor as text_editor_render; -use crate::render::SurfaceId; -use crate::shapes::{Shape, TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; -use crate::state::{State, TextEditorEvent, TextSelection}; +use crate::shapes::{TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; +use crate::state::{State, TextEditorEvent, TextEditorState}; use crate::utils::uuid_from_u32_quartet; use crate::utils::uuid_to_u32_quartet; use crate::uuid::Uuid; @@ -133,8 +131,10 @@ pub extern "C" fn text_editor_select_all() -> bool { }) } -#[no_mangle] -pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) { +fn with_active_text_at_point(x: f32, y: f32, apply: F) +where + F: FnOnce(&mut TextEditorState, &TextContent, &TextPositionWithAffinity), +{ with_state!(state, { if !get_text_editor_state().has_focus { return; @@ -154,11 +154,33 @@ pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) { let point = Point::new(x, y); if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { - get_text_editor_state().select_word_boundary(text_content, &position); + apply(get_text_editor_state(), text_content, &position); } }) } +#[no_mangle] +pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) { + with_active_text_at_point(x, y, |editor, text_content, position| { + editor.select_word_boundary(text_content, position) + }) +} + +#[no_mangle] +pub extern "C" fn text_editor_select_paragraph(x: f32, y: f32) { + // A drag that produced a range must survive the trailing click; a jitter + // that left the caret collapsed must not suppress the paragraph select. + let editor = get_text_editor_state(); + if editor.is_click_event_skipped && editor.selection.is_selection() { + editor.is_click_event_skipped = false; + return; + } + + with_active_text_at_point(x, y, |editor, text_content, position| { + editor.select_paragraph(text_content, position) + }) +} + #[no_mangle] pub extern "C" fn text_editor_poll_event() -> u8 { get_text_editor_state().poll_event() as u8 @@ -192,6 +214,35 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) { }); } +/// Like `text_editor_pointer_down`, but keeps the current anchor and moves the +/// focus to the pointer instead of collapsing the caret there (Shift+click). +#[no_mangle] +pub extern "C" fn text_editor_pointer_down_extend(x: f32, y: f32) { + with_state!(state, { + if !get_text_editor_state().has_focus { + return; + } + let Some(shape_id) = get_text_editor_state().active_shape_id else { + return; + }; + let Some(shape) = state.shapes.get(&shape_id) else { + return; + }; + let Type::Text(text_content) = &shape.shape_type else { + return; + }; + let point = Point::new(x, y); + get_text_editor_state().start_pointer_selection(); + if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { + get_text_editor_state().extend_selection_from_position(&position); + // The click after pointerup would collapse the caret and drop the + // selection we just extended. + get_text_editor_state().is_click_event_skipped = true; + get_text_editor_state().update_styles(text_content); + } + }); +} + #[no_mangle] pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { with_state!(state, { @@ -367,8 +418,7 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> { get_text_editor_state().selection.set_caret(new_cursor); } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); get_text_editor_state().reset_blink(); get_text_editor_state().push_event(crate::state::TextEditorEvent::ContentChanged); @@ -419,8 +469,7 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> { let cursor = get_text_editor_state().selection.focus; text_helpers::insert_text_with_newlines(text_content, &cursor, &text); - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); get_text_editor_state().reset_blink(); get_text_editor_state().push_event(crate::state::TextEditorEvent::ContentChanged); @@ -488,8 +537,7 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> { get_text_editor_state().selection.set_caret(new_cursor); } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); get_text_editor_state().reset_blink(); get_text_editor_state().push_event(TextEditorEvent::ContentChanged); @@ -614,40 +662,6 @@ pub extern "C" fn text_editor_move_cursor( // RENDERING & EXPORT // ============================================================================ -#[no_mangle] -pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 { - with_state!(state, { - if !get_text_editor_state().has_focus || !get_text_editor_state().cursor_visible { - return std::ptr::null_mut(); - } - - let Some(shape_id) = get_text_editor_state().active_shape_id else { - return std::ptr::null_mut(); - }; - - let Some(shape) = state.shapes.get(&shape_id) else { - return std::ptr::null_mut(); - }; - - let Type::Text(text_content) = &shape.shape_type else { - return std::ptr::null_mut(); - }; - - let cursor = &get_text_editor_state().selection.focus; - - if let Some(rect) = get_cursor_rect(text_content, cursor, shape) { - let mut bytes = vec![0u8; 16]; - bytes[0..4].copy_from_slice(&rect.left().to_le_bytes()); - bytes[4..8].copy_from_slice(&rect.top().to_le_bytes()); - bytes[8..12].copy_from_slice(&rect.width().to_le_bytes()); - bytes[12..16].copy_from_slice(&rect.height().to_le_bytes()); - return mem::write_bytes(bytes); - } - - std::ptr::null_mut() - }) -} - #[no_mangle] pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { with_state!(state, { @@ -809,47 +823,6 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { }) } -#[no_mangle] -pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 { - with_state!(state, { - if !get_text_editor_state().has_focus { - return std::ptr::null_mut(); - } - - if get_text_editor_state().selection.is_collapsed() { - return std::ptr::null_mut(); - } - - let Some(shape_id) = get_text_editor_state().active_shape_id else { - return std::ptr::null_mut(); - }; - - let Some(shape) = state.shapes.get(&shape_id) else { - return std::ptr::null_mut(); - }; - - let Type::Text(text_content) = &shape.shape_type else { - return std::ptr::null_mut(); - }; - - let selection = &get_text_editor_state().selection; - let rects = get_selection_rects(text_content, selection, shape); - if rects.is_empty() { - return std::ptr::null_mut(); - } - - let mut bytes = Vec::with_capacity(4 + rects.len() * 16); - bytes.extend_from_slice(&(rects.len() as u32).to_le_bytes()); - for rect in rects { - bytes.extend_from_slice(&rect.left().to_le_bytes()); - bytes.extend_from_slice(&rect.top().to_le_bytes()); - bytes.extend_from_slice(&rect.width().to_le_bytes()); - bytes.extend_from_slice(&rect.height().to_le_bytes()); - } - mem::write_bytes(bytes) - }) -} - #[no_mangle] pub extern "C" fn text_editor_update_blink(timestamp_ms: f32) { get_text_editor_state().update_blink(timestamp_ms); @@ -876,7 +849,7 @@ fn update_text_layout_if_needed(state: &mut State, shape_id: Uuid) { /// Repaint the caret/selection over the last fully rendered frame. /// /// Re-composes Target from the Backbuffer (which still holds the last complete -/// render) and draws the editor overlay on top, in a single submitted frame. +/// render); the compose step draws the editor overlay itself. /// /// This exists because the caret blink must erase the previous caret, which /// means restoring the pixels underneath it. Doing that via `render_from_cache` @@ -892,49 +865,7 @@ pub extern "C" fn text_editor_render_caret() { }; update_text_layout_if_needed(state, shape_id); - - let Some(shape) = state.shapes.get(&shape_id) else { - return; - }; - - get_render_state().compose_frame(&state.shapes); - - let canvas = get_render_state().surfaces.canvas(SurfaceId::Target); - let viewbox = get_render_state().viewbox; - text_editor_render::render_overlay( - canvas, - &viewbox, - &get_render_state().options, - get_text_editor_state(), - shape, - ); - get_render_state().flush_and_submit(); - }); -} - -#[no_mangle] -pub extern "C" fn text_editor_render_overlay() { - with_state!(state, { - let Some(shape_id) = get_text_editor_state().active_shape_id else { - return; - }; - - update_text_layout_if_needed(state, shape_id); - - let Some(shape) = state.shapes.get(&shape_id) else { - return; - }; - - let canvas = get_render_state().surfaces.canvas(SurfaceId::Target); - let viewbox = get_render_state().viewbox; - text_editor_render::render_overlay( - canvas, - &viewbox, - &get_render_state().options, - get_text_editor_state(), - shape, - ); - get_render_state().flush_and_submit(); + get_render_state().present_frame(&state.shapes); }); } @@ -949,11 +880,11 @@ pub extern "C" fn text_editor_export_content() -> *mut u8 { return std::ptr::null_mut(); }; - let Some(shape) = state.shapes.get(&shape_id) else { + let Some(shape) = state.shapes.get_mut(&shape_id) else { return std::ptr::null_mut(); }; - let Type::Text(text_content) = &shape.shape_type else { + let Type::Text(text_content) = &mut shape.shape_type else { return std::ptr::null_mut(); }; @@ -968,12 +899,19 @@ pub extern "C" fn text_editor_export_content() -> *mut u8 { .replace('\n', "\\n") .replace('\r', "\\r") .replace('\t', "\\t"); - span_parts.push(format!("\"{}\"", escaped_text)); + span_parts.push(format!( + "{{\"p\":{},\"s\":{},\"t\":\"{}\"}}", + span.paragraph_position, span.span_position, escaped_text + )); } json_parts.push(format!("[{}]", span_parts.join(","))); } let json = format!("[{}]", json_parts.join(",")); + // The host rebuilds its content tree out of this JSON, so the current + // positions are what the next call has to report against. + text_content.reset_span_positions(); + let mut bytes = json.into_bytes(); bytes.push(0); crate::mem::write_bytes(bytes) @@ -1060,151 +998,38 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 { #[no_mangle] pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> bool { - if !get_text_editor_state().selection.is_selection() { - return false; - } - let sel = &get_text_editor_state().selection; - unsafe { - *buffer_ptr = sel.anchor.paragraph as u32; - *buffer_ptr.add(1) = sel.anchor.offset as u32; - *buffer_ptr.add(2) = sel.focus.paragraph as u32; - *buffer_ptr.add(3) = sel.focus.offset as u32; - } - true -} - -// ============================================================================ -// HELPERS: Cursor & Selection -// ============================================================================ - -fn get_cursor_rect( - text_content: &TextContent, - cursor: &TextPositionWithAffinity, - shape: &Shape, -) -> Option { - let paragraphs = text_content.paragraphs(); - if cursor.paragraph >= paragraphs.len() { - return None; - } - - let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); - - let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum(); - let valign_offset = match shape.vertical_align() { - VerticalAlign::Center => (shape.selrect().height() - total_height) / 2.0, - VerticalAlign::Bottom => shape.selrect().height() - total_height, - _ => 0.0, - }; - - let mut y_offset = valign_offset; - for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() { - if idx == cursor.paragraph { - let char_pos = cursor.offset; - - use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; - let rects = laid_out_para.get_rects_for_range( - char_pos..char_pos, - RectHeightStyle::Tight, - RectWidthStyle::Tight, - ); - - let (x, height) = if !rects.is_empty() { - (rects[0].rect.left(), rects[0].rect.height()) - } else { - let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0)); - let height = laid_out_para.height(); - (pos.position as f32, height) - }; - - let selrect = shape.selrect(); - let base_x = selrect.x(); - let base_y = selrect.y() + y_offset; - - return Some(Rect::from_xywh(base_x + x, base_y, 1.0, height)); - } - y_offset += laid_out_para.height(); - } - - None -} - -/// Get selection rectangles for a given selection. -fn get_selection_rects( - text_content: &TextContent, - selection: &TextSelection, - shape: &Shape, -) -> Vec { - let mut rects = Vec::new(); - - let start = selection.start(); - let end = selection.end(); - - let paragraphs = text_content.paragraphs(); - let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); - - let selrect = shape.selrect(); - - let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum(); - let valign_offset = match shape.vertical_align() { - VerticalAlign::Center => (selrect.height() - total_height) / 2.0, - VerticalAlign::Bottom => selrect.height() - total_height, - _ => 0.0, - }; - - let mut y_offset = valign_offset; - - for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() { - let para_height = laid_out_para.height(); - - if para_idx < start.paragraph || para_idx > end.paragraph { - y_offset += para_height; - continue; + with_state!(state, { + if get_text_editor_state().active_shape_id.is_none() { + return false; } - if para_idx >= paragraphs.len() { - y_offset += para_height; - continue; - } + let sel = get_text_editor_state().selection; - let para = ¶graphs[para_idx]; - let para_char_count: usize = para - .children() - .iter() - .map(|span| span.text.chars().count()) - .sum(); - let range_start = if para_idx == start.paragraph { - start.offset - } else { - 0 - }; - - let range_end = if para_idx == end.paragraph { - end.offset - } else { - para_char_count - }; - - if range_start < range_end { - use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; - let text_boxes = laid_out_para.get_rects_for_range( - range_start..range_end, - RectHeightStyle::Tight, - RectWidthStyle::Tight, - ); - - for text_box in text_boxes { - let r = text_box.rect; - rects.push(Rect::from_xywh( - selrect.x() + r.left(), - selrect.y() + y_offset + r.top(), - r.width(), - r.height(), - )); + // The frontend indexes these offsets into JS strings, which are UTF-16. + let (anchor_offset, focus_offset) = match get_text_editor_state() + .active_shape_id + .and_then(|shape_id| state.shapes.get(&shape_id)) + .map(|shape| &shape.shape_type) + { + Some(Type::Text(text_content)) => { + let paragraphs = text_content.paragraphs(); + let to_utf16 = |position: TextPositionWithAffinity| { + paragraphs + .get(position.paragraph) + .map(|para| para.char_offset_to_utf16(position.offset)) + .unwrap_or(position.offset) + }; + (to_utf16(sel.anchor), to_utf16(sel.focus)) } + _ => (sel.anchor.offset, sel.focus.offset), + }; + + unsafe { + *buffer_ptr = sel.anchor.paragraph as u32; + *buffer_ptr.add(1) = anchor_offset as u32; + *buffer_ptr.add(2) = sel.focus.paragraph as u32; + *buffer_ptr.add(3) = focus_offset as u32; } - - y_offset += para_height; - } - - rects + true + }) } diff --git a/render-wasm/test b/render-wasm/test index f416e6c6bb..8019b1fecd 100755 --- a/render-wasm/test +++ b/render-wasm/test @@ -7,7 +7,7 @@ export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"x86_64-unknown-linux-gnu"}; _SCRIPT_DIR=$(dirname $0); pushd $_SCRIPT_DIR; -. ./_build_env +. ./_build_env frontend cargo test --bin render_wasm -- --show-output diff --git a/render-wasm/watch b/render-wasm/watch index 90c08c9ffd..1825bf7667 100755 --- a/render-wasm/watch +++ b/render-wasm/watch @@ -1,5 +1,7 @@ #!/usr/bin/env bash +# Usage: ./watch [frontend|export] + _SCRIPT_DIR=$(dirname $0); pushd $_SCRIPT_DIR; @@ -7,8 +9,7 @@ pushd $_SCRIPT_DIR; set -x build; -copy_artifacts "../frontend/resources/public/js"; -copy_shared_artifact; +copy_target_artifacts; pushd $_SCRIPT_DIR; @@ -16,7 +17,7 @@ cargo watch \ --why \ -i "_tmp*" \ -x "build $CARGO_PARAMS" \ - -s "./build" \ + -s "./build $RENDER_TARGET" \ -s "echo 'DONE\n'"; popd diff --git a/scripts/ci b/scripts/ci index 3d3c7ed9a8..cff93eca3a 100755 --- a/scripts/ci +++ b/scripts/ci @@ -23,10 +23,10 @@ ALL_MODULES=("frontend" "backend" "common" "render-wasm" "exporter" "mcp" "plugi # Module commands declare -A LINT_CMD=( [frontend]="pnpm run lint:clj && pnpm run lint:js && pnpm run lint:scss" - [backend]="pnpm run lint" + [backend]="pnpm run lint:clj" [common]="pnpm run lint:clj" [render-wasm]="./lint" - [exporter]="pnpm run lint" + [exporter]="pnpm run lint:clj" [mcp]="" [plugins]="pnpm run lint" [library]="pnpm run lint" @@ -37,7 +37,7 @@ declare -A TEST_CMD=( [backend]="clojure -M:dev:test" [common]="clojure -M:dev:test && pnpm run test:quiet" [render-wasm]="./test" - [exporter]="" + [exporter]="pnpm run test:quiet" [mcp]="pnpm run test" [plugins]="pnpm run test" [library]="pnpm run test" @@ -45,24 +45,24 @@ declare -A TEST_CMD=( declare -A FMT_CHECK_CMD=( [frontend]="pnpm run check-fmt:clj && pnpm run check-fmt:js && pnpm run check-fmt:scss" - [backend]="pnpm run check-fmt" + [backend]="pnpm run check-fmt:clj" [common]="pnpm run check-fmt:clj && pnpm run check-fmt:js" [render-wasm]="cargo fmt --check" - [exporter]="pnpm run check-fmt" + [exporter]="pnpm run check-fmt:clj" [mcp]="pnpm run fmt:check" [plugins]="pnpm run format:check" - [library]="pnpm run check-fmt" + [library]="pnpm run check-fmt:clj" ) declare -A FMT_FIX_CMD=( [frontend]="pnpm run fmt:clj && pnpm run fmt:js && pnpm run fmt:scss" - [backend]="pnpm run fmt" + [backend]="pnpm run fmt:clj" [common]="pnpm run fmt:clj && pnpm run fmt:js" [render-wasm]="cargo fmt" - [exporter]="pnpm run fmt" + [exporter]="pnpm run fmt:clj" [mcp]="pnpm run fmt" [plugins]="pnpm run format" - [library]="pnpm run fmt" + [library]="pnpm run fmt:clj" ) declare -A PAREN_REPAIR_CMD=( @@ -70,7 +70,7 @@ declare -A PAREN_REPAIR_CMD=( [backend]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" [common]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" [render-wasm]="" - [exporter]="find src -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" + [exporter]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" [mcp]="" [plugins]="" [library]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" diff --git a/scripts/gh.py b/scripts/gh.py index 7017389d52..f8b4f74d45 100755 --- a/scripts/gh.py +++ b/scripts/gh.py @@ -5,8 +5,9 @@ gh.py — Multi-purpose CLI helper for penpot/penpot GitHub operations. Uses GitHub GraphQL and REST APIs via the authenticated ``gh`` CLI. Subcommands: - issues List issues in a milestone (or unassigned with milestone=none) - prs Fetch details for one or more PRs (by number or milestone) + issues List issues in a milestone (or unassigned with milestone=none) + prs Fetch details for one or more PRs (by number or milestone) + advisories List or inspect GitHub security advisories Usage: python3 scripts/gh.py issues (default: state=closed) @@ -23,6 +24,9 @@ Usage: cat prs.txt | python3 scripts/gh.py prs --stdin python3 scripts/gh.py prs --milestone "2.16.0" (default: state=merged) python3 scripts/gh.py prs --milestone "2.16.0" --state all + python3 scripts/gh.py advisories (list all advisories) + python3 scripts/gh.py advisories --severity critical (filter by severity) + python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7 (single advisory detail) Prerequisites: - gh CLI authenticated (gh auth status) @@ -63,6 +67,16 @@ def run_gh_graphql(query: str, variables: dict) -> Any: return body["data"] +def run_gh_rest(path: str) -> Any: + """Run a REST API call via ``gh api``.""" + cmd = ["gh", "api", path] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"gh error: {result.stderr}", file=sys.stderr) + sys.exit(1) + return json.loads(result.stdout) + + # ───────────────────────────────────────────── # Shared: milestone lookup # ───────────────────────────────────────────── @@ -459,8 +473,10 @@ query($owner: String!, $repo: String!, $milestone: Int!, $cursor: String) { state mergedAt createdAt + headRefName author { login } labels(first: 20) { nodes { name } } + files(first: 100) { nodes { path } } closingIssuesReferences(first: 5) { nodes { number } } } } @@ -480,8 +496,9 @@ def fetch_milestone_prs(milestone_num: int, states: str) -> list[dict]: states: GraphQL states enum array literal, e.g. ``"[MERGED]"`` or ``"[OPEN CLOSED MERGED]"`` Returns: - List of {number, title, body, state, merged_at, created_at, author, - labels: [str], closing_issues: [int]} + List of {number, title, body, state, merged_at, created_at, + head_ref_name, author, labels: [str], files: [str], + closing_issues: [int]} """ query = GQL_MILESTONE_PRS_QUERY.replace("__STATES__", states) all_nodes: list[dict] = [] @@ -508,8 +525,10 @@ def fetch_milestone_prs(milestone_num: int, states: str) -> list[dict]: "state": node["state"], "merged_at": node.get("mergedAt"), "created_at": node.get("createdAt"), + "head_ref_name": node.get("headRefName"), "author": node["author"]["login"] if node["author"] else None, "labels": [lbl["name"] for lbl in node["labels"]["nodes"]], + "files": [file["path"] for file in node["files"]["nodes"]], "closing_issues": [iss["number"] for iss in node["closingIssuesReferences"]["nodes"]], }) @@ -581,6 +600,106 @@ def cmd_prs(args: argparse.Namespace) -> None: print(json.dumps(all_results, indent=2)) +# ───────────────────────────────────────────── +# Subcommand: advisories +# ───────────────────────────────────────────── + + +def fetch_advisories() -> list[dict]: + """Fetch all security advisories for the repository via REST API.""" + all_advisories: list[dict] = [] + page = 1 + + while True: + advisories = run_gh_rest( + f"repos/{REPO}/security-advisories?per_page=100&page={page}" + ) + all_advisories.extend(advisories) + + if len(advisories) < 100: + break + page += 1 + + return all_advisories + + +def fetch_advisory(ghsa_id: str) -> dict: + """Fetch a single security advisory by GHSA ID.""" + return run_gh_rest(f"repos/{REPO}/security-advisories/{ghsa_id}") + + +def format_advisory_summary(adv: dict) -> dict: + """Extract a summary view of an advisory.""" + return { + "ghsa_id": adv["ghsa_id"], + "cve_id": adv.get("cve_id"), + "severity": adv.get("severity"), + "cvss_score": (adv.get("cvss") or {}).get("score"), + "state": adv.get("state"), + "summary": adv.get("summary"), + "cwes": [c["cwe_id"] for c in adv.get("cwes", [])], + "published_at": adv.get("published_at"), + "closed_at": adv.get("closed_at"), + "url": adv.get("html_url"), + } + + +def format_advisory_detail(adv: dict) -> dict: + """Extract full detail view of an advisory.""" + summary = format_advisory_summary(adv) + summary["description"] = adv.get("description") + summary["vulnerabilities"] = [ + { + "package": v.get("package", {}).get("name"), + "vulnerable_version_range": v.get("vulnerable_version_range"), + "patched_versions": v.get("patched_versions"), + } + for v in adv.get("vulnerabilities", []) + ] + summary["credits"] = [ + {"login": c.get("user", {}).get("login"), "type": c.get("type")} + for c in adv.get("credits_detailed", []) + ] + summary["created_at"] = adv.get("created_at") + summary["updated_at"] = adv.get("updated_at") + summary["withdrawn_at"] = adv.get("withdrawn_at") + return summary + + +def cmd_advisories(args: argparse.Namespace) -> None: + """Handle the ``advisories`` subcommand.""" + + # ── Single advisory detail ────────────────────────────── + if args.ghsa_id: + ghsa_id = args.ghsa_id.upper() + if not ghsa_id.startswith("GHSA-"): + ghsa_id = f"GHSA-{ghsa_id}" + print(f"Fetching advisory {ghsa_id}...", file=sys.stderr) + adv = fetch_advisory(ghsa_id) + print(json.dumps(format_advisory_detail(adv), indent=2)) + return + + # ── List all advisories ───────────────────────────────── + print("Fetching security advisories...", file=sys.stderr) + advisories = fetch_advisories() + print(f"Fetched {len(advisories)} advisories", file=sys.stderr) + + results = [format_advisory_summary(adv) for adv in advisories] + + # Apply filters + if args.severity: + sev = args.severity.lower() + results = [r for r in results if (r.get("severity") or "").lower() == sev] + print(f"After severity filter ({sev}): {len(results)} advisories", file=sys.stderr) + + if args.state: + st = args.state.lower() + results = [r for r in results if (r.get("state") or "").lower() == st] + print(f"After state filter ({st}): {len(results)} advisories", file=sys.stderr) + + print(json.dumps(results, indent=2)) + + # ───────────────────────────────────────────── # CLI entrypoint # ───────────────────────────────────────────── @@ -645,6 +764,22 @@ def main() -> None: ) p_prs.set_defaults(func=cmd_prs) + # --- advisories --- + p_adv = sub.add_parser("advisories", help="List or inspect GitHub security advisories") + p_adv.add_argument( + "ghsa_id", nargs="?", + help="GHSA ID to fetch (e.g. 'GHSA-xvj6-fh9w-gjw7'); omit to list all" + ) + p_adv.add_argument( + "--severity", choices=["critical", "high", "medium", "low"], + help="Filter by severity level" + ) + p_adv.add_argument( + "--state", choices=["triage", "draft", "published", "closed", "withdrawn"], + help="Filter by advisory state" + ) + p_adv.set_defaults(func=cmd_advisories) + args = parser.parse_args() args.func(args) diff --git a/scripts/replace-copyright.sh b/scripts/replace-copyright.sh new file mode 100755 index 0000000000..f910d0a20d --- /dev/null +++ b/scripts/replace-copyright.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# +# replace-copyright.sh — Replace KALEIDOS INC copyright references with KALEIDOS SUBSIDIARY SL +# +# Usage: +# scripts/replace-copyright.sh # Execute replacements +# scripts/replace-copyright.sh --dry-run # Simulate without modifying files +# scripts/replace-copyright.sh --help # Show help +# + +set -euo pipefail + +# ── Configuration ────────────────────────────────────────────────────────────── + +EXTENSIONS="clj|cljs|cljc|scss|js|jsx|mdx|md|sh|py|java" + +# ── Colors ───────────────────────────────────────────────────────────────────── + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +# ── Globals ──────────────────────────────────────────────────────────────────── + +DRY_RUN=false +TOTAL_FILES=0 +TOTAL_REPLACEMENTS=0 +declare -A MODULE_FILES +declare -A MODULE_REPLACEMENTS + +# ── Functions ────────────────────────────────────────────────────────────────── + +usage() { + cat <&2 +} + +log_dry() { + echo -e "${YELLOW}[DRY-RUN]${NC} $*" +} + +get_module() { + local file="$1" + local first_dir + first_dir=$(echo "$file" | cut -d'/' -f1) + if [[ "$first_dir" == "$file" || "$first_dir" == "." ]]; then + echo "root" + else + echo "$first_dir" + fi +} + +count_kaleidos() { + local file="$1" + rg -c "KALEIDOS INC" "$file" 2>/dev/null || echo "0" +} + +replace_in_file() { + local file="$1" + local matches replacements module + + matches=$(count_kaleidos "$file") + + if [[ "$matches" -eq 0 ]]; then + return + fi + + if [[ "$DRY_RUN" == false ]]; then + # Order matters: non-accented first, then full, then truncated + perl -pi -e 's/KALEIDOS INC Sucursal en Espana SL/KALEIDOS SUBSIDIARY SL/g' "$file" + perl -pi -e 's/KALEIDOS INC Sucursal en España SL/KALEIDOS SUBSIDIARY SL/g' "$file" + perl -pi -e 's/KALEIDOS INC\b(?!\s+Sucursal)(?!\s+SUBSIDIARY)/KALEIDOS SUBSIDIARY SL/g' "$file" + # Count remaining to compute actual replacements + local remaining + remaining=$(count_kaleidos "$file") + replacements=$(( matches - remaining )) + else + # In dry-run mode, report all matches as potential replacements + replacements=$matches + fi + + if [[ "$replacements" -gt 0 ]]; then + module=$(get_module "$file") + if [[ "$DRY_RUN" == true ]]; then + echo -e " ${YELLOW}[${module}]${NC} ${file}: ${replacements} replacement(s)" + else + echo -e " ${GREEN}[${module}]${NC} ${file}: ${replacements} replacement(s)" + fi + MODULE_REPLACEMENTS["$module"]=$(( ${MODULE_REPLACEMENTS["$module"]:-0} + replacements )) + MODULE_FILES["$module"]=$(( ${MODULE_FILES["$module"]:-0} + 1 )) + TOTAL_REPLACEMENTS=$(( TOTAL_REPLACEMENTS + replacements )) + TOTAL_FILES=$(( TOTAL_FILES + 1 )) + fi +} + +print_summary() { + echo "" + echo -e "${BOLD}=== SUMMARY ===${NC}" + printf "%-15s %6s %14s\n" "Module" "Files" "Replacements" + printf "%-15s %6s %14s\n" "------" "-----" "------------" + + for module in $(printf '%s\n' "${!MODULE_REPLACEMENTS[@]}" | sort); do + printf "%-15s %6d %14d\n" "$module" "${MODULE_FILES[$module]}" "${MODULE_REPLACEMENTS[$module]}" + done + + printf "%-15s %6s %14s\n" "------" "-----" "------------" + printf "${BOLD}%-15s %6d %14d${NC}\n" "TOTAL" "$TOTAL_FILES" "$TOTAL_REPLACEMENTS" +} + +# ── Main ─────────────────────────────────────────────────────────────────────── + +main() { + # Ensure we run from the repo root + cd "$(git rev-parse --show-toplevel)" + + # Parse arguments + while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + DRY_RUN=true + shift + ;; + --help|-h) + usage + ;; + *) + log_error "Unknown option: $1" + usage + ;; + esac + done + + # Header + if [[ "$DRY_RUN" == true ]]; then + log_dry "Starting copyright replacement (simulation mode)..." + else + log_info "Starting copyright replacement..." + fi + + # Check dependencies + for cmd in rg perl git; do + if ! command -v "$cmd" &>/dev/null; then + log_error "Required command not found: $cmd" + exit 1 + fi + done + + # Get tracked files matching our extensions + local files + files=$(git ls-files | grep -E "\.(${EXTENSIONS})$" || true) + + if [[ -z "$files" ]]; then + log_warn "No tracked files found matching extensions: ${EXTENSIONS}" + exit 0 + fi + + local file_count + file_count=$(echo "$files" | wc -l) + log_info "Found ${file_count} tracked files to scan" + + if [[ "$DRY_RUN" == true ]]; then + log_dry "Would process files (no changes will be made)" + fi + + echo "" + + # Process each file + while IFS= read -r file; do + replace_in_file "$file" + done <<< "$files" + + # Print summary + print_summary + + # Exit code + if [[ "$DRY_RUN" == true ]]; then + echo "" + log_dry "No files were modified. Run without --dry-run to apply changes." + fi +} + +main "$@"