diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 1e0cf5a4b7c..00000000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Docs - -# Temporarily disable the workflow except when manually triggered -# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch -on: workflow_dispatch - -# on: -# push: -# branches: -# - main - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - COPILOT_AGENT_FIREWALL_ALLOW_LIST_ADDITIONS: googlechromelabs.github.io,storage.googleapis.com - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - steps: - - name: Checkout 🛎️ - uses: actions/checkout@v6 - with: - # For GitHub Pages deployment, persist-credentials: false is often required. - persist-credentials: false - - - name: Install npm dependencies 🛠 - run: yarn - - - name: Build 🔧 - run: yarn docs - - - name: NoJekyll 🎃 - run: touch docs/.nojekyll - - - name: Deploy 🚀 - uses: JamesIves/github-pages-deploy-action@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - branch: gh-pages - folder: docs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index b0b5f7afd54..00000000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Lint - -on: - push: - branches: - - main - pull_request: - branches: - - '**' - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - CI: true - COPILOT_AGENT_FIREWALL_ALLOW_LIST_ADDITIONS: googlechromelabs.github.io,storage.googleapis.com - -jobs: - run: - name: Lint - runs-on: ubuntu-latest - if: github.event_name != 'pull_request' || github.event.pull_request.changed_files > 0 - - steps: - - name: Clone repository - uses: actions/checkout@v6 - - - name: Enable Corepack - run: corepack enable - - - name: Set Node.js version - uses: actions/setup-node@v6 - with: - node-version: 22 - - - name: Install npm dependencies - run: yarn - - - name: Lint - run: yarn lint diff --git a/.github/workflows/puppeteer.yml b/.github/workflows/puppeteer.yml deleted file mode 100644 index f7a85c3da15..00000000000 --- a/.github/workflows/puppeteer.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Puppeteer - -on: - push: - branches: - - main - pull_request: - branches: - - '**' - workflow_dispatch: - inputs: - rerun_id: - description: 'Optional ID for tracking repeated runs' - required: false - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - CI: true - COPILOT_AGENT_FIREWALL_ALLOW_LIST_ADDITIONS: googlechromelabs.github.io,storage.googleapis.com - -jobs: - run: - name: Puppeteer - runs-on: ubuntu-latest - if: github.event_name != 'pull_request' || github.event.pull_request.changed_files > 0 - services: - browserless: - image: browserless/chrome:latest - ports: - - 7566:3000 - env: - CONNECTION_TIMEOUT: -1 - - steps: - - name: Clone repository - uses: actions/checkout@v6 - - - name: Enable Corepack - run: corepack enable - - - name: Set Node.js version - uses: actions/setup-node@v6 - with: - node-version: 22 - - - name: Install npm dependencies - run: yarn - - - name: Start SSL proxy - run: docker run -d --rm --add-host=host.docker.internal:host-gateway -e "PORT=3000" -p 2552:443 esplo/docker-local-ssl-termination-proxy - - - name: Build - run: yarn build - - - name: Serve - uses: ./.github/actions/serve - - - name: Test - run: yarn test:puppeteer - - - name: Upload snapshot diff artifact - if: failure() - uses: actions/upload-artifact@v7 - with: - name: __diff_output__ - path: src/e2e/puppeteer/__tests__/__image_snapshots__/**/__diff_output__/ - if-no-files-found: ignore diff --git a/.github/workflows/tdd.yml b/.github/workflows/tdd.yml deleted file mode 100644 index 2f0dc11dc58..00000000000 --- a/.github/workflows/tdd.yml +++ /dev/null @@ -1,596 +0,0 @@ -name: TDD - -on: - pull_request: - branches: - - '**' - workflow_dispatch: - inputs: - rerun_id: - description: 'Optional ID for tracking repeated runs' - required: false - -permissions: - contents: read - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - CI: true - COPILOT_AGENT_FIREWALL_ALLOW_LIST_ADDITIONS: googlechromelabs.github.io,storage.googleapis.com - -jobs: - # --------------------------------------------------------------------------- - # Detect: classify changed files, decide which downstream jobs to run. - # --------------------------------------------------------------------------- - detect: - name: Detect changed tests - runs-on: ubuntu-latest - if: github.event_name != 'pull_request' || github.event.pull_request.changed_files > 0 - outputs: - has_unit: ${{ steps.classify.outputs.has_unit }} - has_puppeteer: ${{ steps.classify.outputs.has_puppeteer }} - has_ios: ${{ steps.classify.outputs.has_ios }} - has_skipped_unit: ${{ steps.classify.outputs.has_skipped_unit }} - has_skipped_puppeteer: ${{ steps.classify.outputs.has_skipped_puppeteer }} - unit_files: ${{ steps.classify.outputs.unit_files }} - puppeteer_files: ${{ steps.classify.outputs.puppeteer_files }} - ios_files: ${{ steps.classify.outputs.ios_files }} - skipped_unit_files: ${{ steps.classify.outputs.skipped_unit_files }} - skipped_puppeteer_files: ${{ steps.classify.outputs.skipped_puppeteer_files }} - merge_base: ${{ steps.classify.outputs.merge_base }} - tdd_commit: ${{ steps.tdd_commit.outputs.tdd_commit }} - skip: ${{ steps.skip.outputs.skip }} - skip_reason: ${{ steps.skip.outputs.reason }} - skip_unit: ${{ steps.skip.outputs.skip_unit }} - skip_puppeteer: ${{ steps.skip.outputs.skip_puppeteer }} - - steps: - - name: Clone repository - uses: actions/checkout@v6 - with: - fetch-depth: 999 - - - name: Classify changed files - id: classify - run: | - BASE_SHA="${{ github.event.pull_request.base.sha }}" - HEAD_SHA="${{ github.event.pull_request.head.sha }}" - - echo "Base SHA: $BASE_SHA" - echo "Head SHA: $HEAD_SHA" - - # Compute the merge base so we only diff files changed in this PR, - # not files from other PRs merged into the base branch since the PR - # was originally branched off. - MERGE_BASE=$(git merge-base "$BASE_SHA" "$HEAD_SHA") - echo "Merge base: $MERGE_BASE" - - # All changed files in the PR - CHANGED_FILES=$(git diff --name-only "$MERGE_BASE" "$HEAD_SHA" --) - - echo "=== All changed files ===" - echo "$CHANGED_FILES" - - # Classify test files into categories - # Unit tests: any __tests__/**/*.ts EXCLUDING e2e directories - UNIT_FILES=$(echo "$CHANGED_FILES" | grep -E '^src/.*/__tests__/.*\.ts$' | grep -v '^src/e2e/' || true) - - # Puppeteer tests: src/e2e/puppeteer/__tests__/*.ts - PUPPETEER_FILES=$(echo "$CHANGED_FILES" | grep -E '^src/e2e/puppeteer/__tests__/.*\.ts$' || true) - - # iOS tests: src/e2e/iOS/__tests__/**/*.ts - IOS_FILES=$(echo "$CHANGED_FILES" | grep -E '^src/e2e/iOS/__tests__/.*\.ts$' || true) - - # Filter a newline-separated list of test files to only those where the diff - # adds new test definitions (new it() or test() calls). This prevents triggering - # TDD for changes that only modify existing test internals (e.g. replacing - # sleep() with waitUntil()). Matches both it(...) and test(...) regardless of - # indentation. Usage: filter_new_tests "$FILES" "$MERGE_BASE" "$HEAD_SHA" - filter_new_tests() { - local files="$1" merge_base="$2" head_sha="$3" result="" - for f in $files; do - if git diff "$merge_base" "$head_sha" -- "$f" | grep -qE '^\+[ \t]*(it|test)\b'; then - result="$result"$'\n'"$f" - fi - done - echo "$result" | sed '/^$/d' - } - - # Filter a newline-separated list of test files to those where the diff adds - # new skipped test definitions (it.skip/test.skip). These need a special TDD - # flow that unskips them on base before execution. - # Usage: filter_added_skipped_tests "$FILES" "$MERGE_BASE" "$HEAD_SHA" - filter_added_skipped_tests() { - local files="$1" merge_base="$2" head_sha="$3" result="" - for f in $files; do - if git diff "$merge_base" "$head_sha" -- "$f" | grep -qE '^\+[ \t]*(it|test)\.skip\b'; then - result="$result"$'\n'"$f" - fi - done - echo "$result" | sed '/^$/d' - } - - UNIT_SKIPPED_FILES=$(filter_added_skipped_tests "$UNIT_FILES" "$MERGE_BASE" "$HEAD_SHA") - PUPPETEER_SKIPPED_FILES=$(filter_added_skipped_tests "$PUPPETEER_FILES" "$MERGE_BASE" "$HEAD_SHA") - - UNIT_FILES=$(filter_new_tests "$UNIT_FILES" "$MERGE_BASE" "$HEAD_SHA") - PUPPETEER_FILES=$(filter_new_tests "$PUPPETEER_FILES" "$MERGE_BASE" "$HEAD_SHA") - IOS_FILES=$(filter_new_tests "$IOS_FILES" "$MERGE_BASE" "$HEAD_SHA") - - # All test files (union of the above) - ALL_TEST_FILES=$(printf '%s\n' "$UNIT_FILES" "$PUPPETEER_FILES" "$IOS_FILES" | sed '/^$/d' | sort -u) - - # Non-test application files: TypeScript source files that are not test files, - # test helpers, or e2e infrastructure (helpers, config, setup). - # Excludes non-source files (markdown, yaml, json, js, css, html) to focus - # on actual application logic changes. - NON_TEST_FILES=$(echo "$CHANGED_FILES" \ - | grep -v -E '(/__tests__/|/test-helpers/|/e2e/.*/(helpers|config|setup)/)' \ - | grep -v -E '(\.md$|\.yml$|\.json$|\.js$|\.css$|\.html$)' \ - | grep -E '\.(ts|tsx)$' || true) - - echo "" - echo "=== Unit test files ===" - echo "$UNIT_FILES" - echo "" - echo "=== Puppeteer test files ===" - echo "$PUPPETEER_FILES" - echo "" - echo "=== iOS test files ===" - echo "$IOS_FILES" - echo "" - echo "=== Unit files with added skipped tests ===" - echo "$UNIT_SKIPPED_FILES" - echo "" - echo "=== Puppeteer files with added skipped tests ===" - echo "$PUPPETEER_SKIPPED_FILES" - echo "" - echo "=== Non-test application files ===" - echo "$NON_TEST_FILES" - - # Set outputs - HAS_UNIT="false" - HAS_PUPPETEER="false" - HAS_IOS="false" - HAS_SKIPPED_UNIT="false" - HAS_SKIPPED_PUPPETEER="false" - - if [ -n "$UNIT_FILES" ]; then HAS_UNIT="true"; fi - if [ -n "$PUPPETEER_FILES" ]; then HAS_PUPPETEER="true"; fi - if [ -n "$IOS_FILES" ]; then HAS_IOS="true"; fi - if [ -n "$UNIT_SKIPPED_FILES" ]; then HAS_SKIPPED_UNIT="true"; fi - if [ -n "$PUPPETEER_SKIPPED_FILES" ]; then HAS_SKIPPED_PUPPETEER="true"; fi - - echo "has_unit=$HAS_UNIT" >> "$GITHUB_OUTPUT" - echo "has_puppeteer=$HAS_PUPPETEER" >> "$GITHUB_OUTPUT" - echo "has_ios=$HAS_IOS" >> "$GITHUB_OUTPUT" - echo "has_skipped_unit=$HAS_SKIPPED_UNIT" >> "$GITHUB_OUTPUT" - echo "has_skipped_puppeteer=$HAS_SKIPPED_PUPPETEER" >> "$GITHUB_OUTPUT" - - # Store file lists (newline-separated) for downstream jobs - # Use base64 to safely pass through GITHUB_OUTPUT - echo "unit_files=$(echo "$UNIT_FILES" | base64 -w 0)" >> "$GITHUB_OUTPUT" - echo "puppeteer_files=$(echo "$PUPPETEER_FILES" | base64 -w 0)" >> "$GITHUB_OUTPUT" - echo "ios_files=$(echo "$IOS_FILES" | base64 -w 0)" >> "$GITHUB_OUTPUT" - echo "skipped_unit_files=$(echo "$UNIT_SKIPPED_FILES" | base64 -w 0)" >> "$GITHUB_OUTPUT" - echo "skipped_puppeteer_files=$(echo "$PUPPETEER_SKIPPED_FILES" | base64 -w 0)" >> "$GITHUB_OUTPUT" - echo "merge_base=$MERGE_BASE" >> "$GITHUB_OUTPUT" - - # Store whether any non-test app code changed - HAS_APP_CHANGES="false" - if [ -n "$NON_TEST_FILES" ]; then HAS_APP_CHANGES="true"; fi - echo "has_app_changes=$HAS_APP_CHANGES" >> "$GITHUB_OUTPUT" - - echo "" - echo "=== Summary ===" - echo "Has unit tests: $HAS_UNIT" - echo "Has puppeteer tests: $HAS_PUPPETEER" - echo "Has iOS tests: $HAS_IOS" - echo "Has unit skipped tests: $HAS_SKIPPED_UNIT" - echo "Has puppeteer skipped tests: $HAS_SKIPPED_PUPPETEER" - echo "Has app code changes: $HAS_APP_CHANGES" - - - name: Parse /tdd command from PR description - id: tdd_commit - env: - PR_BODY: ${{ github.event.pull_request.body }} - run: | - BASE_SHA="${{ github.event.pull_request.base.sha }}" - - # Look for /tdd at the beginning of a line (7–40 lowercase hex chars) - TDD_COMMIT=$(printf '%s' "$PR_BODY" | grep -oP '^/tdd\s+\K[a-f0-9]{7,40}' | head -1 || true) - - if [ -n "$TDD_COMMIT" ]; then - # Validate the commit exists in the repository - if git cat-file -t "$TDD_COMMIT" >/dev/null 2>&1; then - echo "Found /tdd command — using commit: $TDD_COMMIT" - echo "tdd_commit=$TDD_COMMIT" >> "$GITHUB_OUTPUT" - else - echo "::warning::/tdd commit '$TDD_COMMIT' not found in repository — falling back to base SHA: $BASE_SHA" - echo "tdd_commit=$BASE_SHA" >> "$GITHUB_OUTPUT" - fi - else - echo "No /tdd command found — using base SHA: $BASE_SHA" - echo "tdd_commit=$BASE_SHA" >> "$GITHUB_OUTPUT" - fi - - - name: Check skip conditions - id: skip - run: | - HAS_UNIT="${{ steps.classify.outputs.has_unit }}" - HAS_PUPPETEER="${{ steps.classify.outputs.has_puppeteer }}" - HAS_IOS="${{ steps.classify.outputs.has_ios }}" - HAS_APP_CHANGES="${{ steps.classify.outputs.has_app_changes }}" - HAS_SKIPPED_UNIT="${{ steps.classify.outputs.has_skipped_unit }}" - HAS_SKIPPED_PUPPETEER="${{ steps.classify.outputs.has_skipped_puppeteer }}" - BASE_SHA="${{ github.event.pull_request.base.sha }}" - HEAD_SHA="${{ github.event.pull_request.head.sha }}" - - SKIP="false" - REASON="" - SKIP_UNIT="false" - SKIP_PUPPETEER="false" - - # 1. Check for skip-tdd label - LABELS='${{ toJSON(github.event.pull_request.labels.*.name) }}' - if echo "$LABELS" | grep -q '"skip-tdd"'; then - SKIP="true" - REASON="PR has skip-tdd label" - fi - - # 1b. Check for granular skip labels - if echo "$LABELS" | grep -q '"skip-tdd-unit"'; then - SKIP_UNIT="true" - REASON="PR has skip-tdd-unit label" - fi - if echo "$LABELS" | grep -q '"skip-tdd-puppeteer"'; then - SKIP_PUPPETEER="true" - REASON="${REASON:+$REASON; }PR has skip-tdd-puppeteer label" - fi - - # 2. No changed tests at all - if [ "$SKIP" = "false" ] && [ "$HAS_UNIT" = "false" ] && [ "$HAS_PUPPETEER" = "false" ] && [ "$HAS_IOS" = "false" ]; then - SKIP="true" - REASON="No changed test files detected" - fi - - # 3. Only test files changed, no application code changed (coverage-only) - # Keep running when skipped tests were added so they can be unskipped and - # validated against base. - if [ "$SKIP" = "false" ] \ - && [ "$HAS_APP_CHANGES" = "false" ] \ - && [ "$HAS_SKIPPED_UNIT" = "false" ] \ - && [ "$HAS_SKIPPED_PUPPETEER" = "false" ]; then - SKIP="true" - REASON="Only test files changed (no application code) — likely coverage-only" - fi - - echo "skip=$SKIP" >> "$GITHUB_OUTPUT" - echo "reason=$REASON" >> "$GITHUB_OUTPUT" - echo "skip_unit=$SKIP_UNIT" >> "$GITHUB_OUTPUT" - echo "skip_puppeteer=$SKIP_PUPPETEER" >> "$GITHUB_OUTPUT" - - echo "=== Skip decision ===" - echo "Skip: $SKIP" - echo "Reason: $REASON" - echo "Skip unit: $SKIP_UNIT" - echo "Skip puppeteer: $SKIP_PUPPETEER" - - # --------------------------------------------------------------------------- - # Unit: apply changed unit test diff to base, run them, expect failure. - # --------------------------------------------------------------------------- - unit: - name: TDD — Unit tests - needs: detect - if: needs.detect.outputs.skip != 'true' && needs.detect.outputs.skip_unit != 'true' && needs.detect.outputs.has_unit == 'true' - runs-on: ubuntu-latest - - steps: - - name: Clone repository - uses: actions/checkout@v6 - with: - ref: ${{ needs.detect.outputs.tdd_commit }} - fetch-depth: 999 - - - name: Fetch PR head - run: git fetch origin ${{ github.event.pull_request.head.sha }} - - - name: Checkout test files from PR head - id: patch - run: | - # Decode the file list - UNIT_FILES=$(echo "${{ needs.detect.outputs.unit_files }}" | base64 -d) - - echo "=== Changed unit test files ===" - echo "$UNIT_FILES" - - if [ -z "$UNIT_FILES" ]; then - echo "No unit test files to process" - echo "empty=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Check out each test file directly from the PR head. This reliably handles - # both modified files and brand-new test files (including new directories), - # where git apply --3way can fail because there is no base blob to merge. - echo "=== Checking out test files from PR head ===" - for f in $UNIT_FILES; do - echo "Checking out: $f" - git checkout FETCH_HEAD -- "$f" - done - - echo "empty=false" >> "$GITHUB_OUTPUT" - - - name: Enable Corepack - if: steps.patch.outputs.empty != 'true' - run: corepack enable - - - name: Unskip added unit tests before running on base - if: steps.patch.outputs.empty != 'true' && needs.detect.outputs.has_skipped_unit == 'true' - uses: ./.github/actions/unskip-added-tests - with: - merge-base: ${{ needs.detect.outputs.merge_base }} - head-sha: ${{ github.event.pull_request.head.sha }} - skipped-files: ${{ needs.detect.outputs.skipped_unit_files }} - no-files-message: No unit skipped test files to unskip. - - - name: Set Node.js version - if: steps.patch.outputs.empty != 'true' - uses: actions/setup-node@v6 - with: - node-version: 22 - - - name: Install npm dependencies - if: steps.patch.outputs.empty != 'true' - run: yarn - - - name: Run changed unit tests on base (expect failure) - if: steps.patch.outputs.empty != 'true' - id: run_tests - run: | - UNIT_FILES=$(echo "${{ needs.detect.outputs.unit_files }}" | base64 -d) - - echo "=== Running changed unit tests ===" - echo "$UNIT_FILES" - - # Run only the changed test files - # vitest accepts file paths as positional args - set +e - yarn vitest run --project unit $UNIT_FILES 2>&1 - TEST_EXIT=$? - set -e - - echo "test_exit=$TEST_EXIT" >> "$GITHUB_OUTPUT" - - if [ "$TEST_EXIT" -ne 0 ]; then - echo "" - echo "✅ TDD validated: changed tests FAIL on the base branch (exit code $TEST_EXIT)." - echo "This means the tests are genuinely covering new behavior." - else - echo "" - echo "❌ TDD flagged: changed tests PASS on the base branch." - echo "New tests that accompany bug fixes are expected to FAIL on the base branch and PASS on the PR, demonstrating that the issue has been fixed. Please review your tests and make sure they fail on the base branch." - echo "If you are extending the test coverage over previously working behavior, add the 'skip-tdd' or 'skip-tdd-unit' label to the PR to skip this workflow." - exit 1 - fi - - # --------------------------------------------------------------------------- - # Puppeteer: apply changed puppeteer test diff to base, run them, expect failure. - # --------------------------------------------------------------------------- - puppeteer: - name: TDD — Puppeteer tests - needs: detect - if: needs.detect.outputs.skip != 'true' && needs.detect.outputs.skip_puppeteer != 'true' && needs.detect.outputs.has_puppeteer == 'true' - runs-on: ubuntu-latest - services: - browserless: - image: browserless/chrome:latest - ports: - - 7566:3000 - env: - CONNECTION_TIMEOUT: -1 - - steps: - - name: Clone repository - uses: actions/checkout@v6 - with: - ref: ${{ needs.detect.outputs.tdd_commit }} - fetch-depth: 999 - - - name: Fetch PR head - run: git fetch origin ${{ github.event.pull_request.head.sha }} - - - name: Restore GitHub Actions files from PR HEAD - if: github.event_name == 'pull_request' - run: git checkout FETCH_HEAD -- .github/actions/ - - - name: Checkout test files from PR head - id: patch - run: | - PUPPETEER_FILES=$(echo "${{ needs.detect.outputs.puppeteer_files }}" | base64 -d) - - echo "=== Changed puppeteer test files ===" - echo "$PUPPETEER_FILES" - - if [ -z "$PUPPETEER_FILES" ]; then - echo "No puppeteer test files to process" - echo "empty=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Check out each test file directly from the PR head. This reliably handles - # both modified files and brand-new test files (including new directories), - # where git apply --3way can fail because there is no base blob to merge. - echo "=== Checking out puppeteer test files from PR head ===" - for f in $PUPPETEER_FILES; do - echo "Checking out: $f" - git checkout FETCH_HEAD -- "$f" - done - - echo "empty=false" >> "$GITHUB_OUTPUT" - - - name: Enable Corepack - if: steps.patch.outputs.empty != 'true' - run: corepack enable - - - name: Unskip added puppeteer tests before running on base - if: steps.patch.outputs.empty != 'true' && needs.detect.outputs.has_skipped_puppeteer == 'true' - uses: ./.github/actions/unskip-added-tests - with: - merge-base: ${{ needs.detect.outputs.merge_base }} - head-sha: ${{ github.event.pull_request.head.sha }} - skipped-files: ${{ needs.detect.outputs.skipped_puppeteer_files }} - no-files-message: No puppeteer skipped test files to unskip. - - - name: Set Node.js version - if: steps.patch.outputs.empty != 'true' - uses: actions/setup-node@v6 - with: - node-version: 22 - - - name: Install npm dependencies - if: steps.patch.outputs.empty != 'true' - run: yarn - - - name: Start SSL proxy - if: steps.patch.outputs.empty != 'true' - run: docker run -d --rm --add-host=host.docker.internal:host-gateway -e "PORT=3000" -p 2552:443 esplo/docker-local-ssl-termination-proxy - - - name: Build - if: steps.patch.outputs.empty != 'true' - run: yarn build - - - name: Serve - if: steps.patch.outputs.empty != 'true' - uses: ./.github/actions/serve - - - name: Run changed puppeteer tests on base (expect failure) - if: steps.patch.outputs.empty != 'true' - id: run_tests - run: | - PUPPETEER_FILES=$(echo "${{ needs.detect.outputs.puppeteer_files }}" | base64 -d) - - echo "=== Running changed puppeteer tests ===" - echo "$PUPPETEER_FILES" - - set +e - yarn vitest run --project puppeteer-e2e $PUPPETEER_FILES 2>&1 - TEST_EXIT=$? - set -e - - echo "test_exit=$TEST_EXIT" >> "$GITHUB_OUTPUT" - - if [ "$TEST_EXIT" -ne 0 ]; then - echo "" - echo "✅ TDD validated: changed puppeteer tests FAIL on the base branch (exit code $TEST_EXIT)." - echo "This means the tests are genuinely covering new behavior." - else - echo "" - echo "❌ TDD flagged: changed puppeteer tests PASS on the base branch." - echo "New tests that accompany bug fixes are expected to FAIL on the base branch and PASS on the PR, demonstrating that the issue has been fixed. Please review your tests and make sure they fail on the base branch." - echo "If you are extending the test coverage over previously working behavior, add the 'skip-tdd' or 'skip-tdd-unit' label to the PR to skip this workflow." - exit 1 - fi - - # --------------------------------------------------------------------------- - # iOS BrowserStack: skipped in v1. - # - # BrowserStack iOS tests require BROWSERSTACK_USERNAME and - # BROWSERSTACK_ACCESS_KEY secrets, a running local server tunneled via - # BrowserStack Local, and real device time. Running these against the base - # branch just for TDD validation is expensive and complex. - # - # For now, if iOS tests change alongside app code, we note it but do not - # gate the PR. A future iteration can add this when cost/benefit is clearer. - # --------------------------------------------------------------------------- - ios: - name: TDD — iOS (skipped) - needs: detect - if: needs.detect.outputs.skip != 'true' && needs.detect.outputs.has_ios == 'true' - runs-on: ubuntu-latest - steps: - - name: iOS TDD check not yet implemented - run: | - echo "⚠️ Changed iOS test files detected, but BrowserStack TDD validation" - echo "is not yet implemented (requires secrets, device time, tunneling)." - echo "" - echo "Changed files:" - echo "${{ needs.detect.outputs.ios_files }}" | base64 -d - echo "" - echo "Skipping — this will be addressed in a future iteration." - - # --------------------------------------------------------------------------- - # Summary: single status check that downstream branch protection can require. - # --------------------------------------------------------------------------- - summary: - name: TDD - runs-on: ubuntu-latest - if: always() - needs: [detect, unit, puppeteer, ios] - steps: - - name: Evaluate results - run: | - DETECT_RESULT="${{ needs.detect.result }}" - SKIP="${{ needs.detect.outputs.skip }}" - SKIP_REASON="${{ needs.detect.outputs.skip_reason }}" - SKIP_UNIT="${{ needs.detect.outputs.skip_unit }}" - SKIP_PUPPETEER="${{ needs.detect.outputs.skip_puppeteer }}" - - echo "=== TDD Validation Summary ===" - - if [ "$DETECT_RESULT" = "skipped" ]; then - echo "⏭️ Skipped: No file changes" - exit 0 - fi - - if [ "$SKIP" = "true" ]; then - echo "⏭️ Skipped: $SKIP_REASON" - exit 0 - fi - - if [ -n "$SKIP_REASON" ]; then - echo "⏭️ Partial skip: $SKIP_REASON" - fi - - # Check each job result - UNIT_RESULT="${{ needs.unit.result }}" - PUPPETEER_RESULT="${{ needs.puppeteer.result }}" - IOS_RESULT="${{ needs.ios.result }}" - - echo "Unit: $UNIT_RESULT" - echo "Puppeteer: $PUPPETEER_RESULT" - echo "iOS: $IOS_RESULT" - echo "" - - FAILED="false" - - # A job result of "failure" means the tests passed on base (TDD violation) - # A job result of "success" means the tests failed on base (TDD validated) - # A job result of "skipped" means no changed tests of that type - - if [ "$UNIT_RESULT" = "failure" ]; then - echo "❌ Unit tests passed on base branch — TDD flagged" - FAILED="true" - fi - - if [ "$PUPPETEER_RESULT" = "failure" ]; then - echo "❌ Puppeteer tests passed on base branch — TDD flagged" - FAILED="true" - fi - - # iOS is informational only in v1 - if [ "$IOS_RESULT" = "failure" ]; then - echo "⚠️ iOS job had an issue (informational only)" - fi - - if [ "$FAILED" = "true" ]; then - echo "" - echo "TDD validation failed. Changed tests should fail on the base branch" - echo "to prove they cover new functionality. If this is intentional, add the" - echo "'skip-tdd' label to skip all TDD checks, or use 'skip-tdd-unit' /" - echo "'skip-tdd-puppeteer' to skip only the relevant job." - exit 1 - fi - - echo "" - echo "✅ TDD validation passed!" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 730cce11624..00000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Test - -on: - push: - branches: - - main - pull_request: - branches: - - '**' - workflow_dispatch: - inputs: - rerun_id: - description: 'Optional ID for tracking repeated runs' - required: false - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - CI: true - COPILOT_AGENT_FIREWALL_ALLOW_LIST_ADDITIONS: googlechromelabs.github.io,storage.googleapis.com - -jobs: - run: - name: Test - runs-on: ubuntu-latest - if: github.event_name != 'pull_request' || github.event.pull_request.changed_files > 0 - - steps: - - name: Clone repository - uses: actions/checkout@v6 - - - name: Enable Corepack - run: corepack enable - - - name: Set Node.js version - uses: actions/setup-node@v6 - with: - node-version: 22 - - - name: Install npm dependencies - run: yarn - - - name: Test - run: yarn test diff --git a/src/@types/MergePrevActionPayload.ts b/src/@types/MergePrevActionPayload.ts new file mode 100644 index 00000000000..6a03b50d59d --- /dev/null +++ b/src/@types/MergePrevActionPayload.ts @@ -0,0 +1,8 @@ +// Some edits related to formatting, e.g. stripping empty tags or applying foreground and background color as part of the same command, are constrained +// in ways that force them to be applied as separate actions. It is necessary to force-merge them into a single undo step even when it goes against +// editThought merge rules in undoRedoEnhancer (#3905). +interface MergePrevActionPayload { + mergePrev?: boolean +} + +export default MergePrevActionPayload diff --git a/src/actions/addAllMulticursor.ts b/src/actions/addAllMulticursor.ts index 2f51400fcc3..91711dfee3c 100644 --- a/src/actions/addAllMulticursor.ts +++ b/src/actions/addAllMulticursor.ts @@ -28,13 +28,13 @@ const addAllMulticursor = (state: State): State => { /** Action-creator for addAllMulticursor. */ export const addAllMulticursorActionCreator = ({ - mergeUndo, + mergeNext, }: { - /** Forces the next command to be merged into this during chained gestures. Note that mergeUndo is not referenced by the reducer at all; it is read directly from the action object by undoRedoEnhancer. */ - mergeUndo?: boolean + /** Forces the next command to be merged into this during chained gestures. Note that mergeNext is not referenced by the reducer at all; it is read directly from the action object by undoRedoEnhancer. */ + mergeNext?: boolean }): Thunk => dispatch => - dispatch({ type: 'addAllMulticursor', mergeUndo }) + dispatch({ type: 'addAllMulticursor', mergeNext }) export default _.curryRight(addAllMulticursor) diff --git a/src/actions/editThought.ts b/src/actions/editThought.ts index 719bcbff821..2f478e8b007 100644 --- a/src/actions/editThought.ts +++ b/src/actions/editThought.ts @@ -1,6 +1,7 @@ import _ from 'lodash' import Index from '../@types/IndexType' import Lexeme from '../@types/Lexeme' +import MergePrevActionPayload from '../@types/MergePrevActionPayload' import SimplePath from '../@types/SimplePath' import State from '../@types/State' import Thought from '../@types/Thought' @@ -30,7 +31,7 @@ import deleteThought from './deleteThought' import setCursor from './setCursor' import updateThoughts from './updateThoughts' -export interface editThoughtPayload { +export interface editThoughtPayload extends MergePrevActionPayload { cursorOffset?: number /** Force the Editable to re-render. */ // TODO: This is used to force the Editable to re-render on generateThought, which co-opts clearThought during its pending state. Is there a better way to do this? diff --git a/src/actions/formatSelection.ts b/src/actions/formatSelection.ts index e909ed15331..f201947747e 100644 --- a/src/actions/formatSelection.ts +++ b/src/actions/formatSelection.ts @@ -1,7 +1,6 @@ /* eslint-disable import/prefer-default-export */ import Thunk from '../@types/Thunk' import { ColorToken } from '../colors.config' -import { ALLOWED_ATTR } from '../constants' import * as selection from '../device/selection' import getThoughtById from '../selectors/getThoughtById' import noteValue from '../selectors/noteValue' @@ -68,82 +67,63 @@ export const formatSelectionActionCreator = suppressFocusStore.update(false) - if (command === 'backColor') { - if (color === 'bg') { - /** Function to check if a style(background) should be removed based on the color and background-color. */ - const shouldRemoveStyle = (styleString: string) => { - const styleLower = styleString.toLowerCase() - const colorMatch = styleLower.match(/background-color\s*:\s*([^;]+);?/) - const elementColor = colorMatch ? colorMatch[1].trim() : null - const isSameColor = elementColor && rgbToHex(elementColor) === rgbToHex(colors.bg) - if (elementColor && isSameColor) return true - return false - } + if (command === 'backColor' || command === 'foreColor') { + dispatch((dispatch, getState) => { + const state = getState() + if (!state.cursor) return - /** Function to collect tag names without significant attributes. */ - const collectTagsWithoutAttributes = (text: string, pattern: RegExp): string[] => - Array.from(text.matchAll(pattern)) - // Filter out tags that lack meaningful attributes - .filter(([, , attributes]) => { - const meaningfulAttributes = ALLOWED_ATTR.map(attr => `${attr}=`) - // Return true if attributes are absent or do not contain any meaningful attributes - return !attributes || !meaningfulAttributes.some(attr => attributes.includes(attr)) - }) - // Map to extract the tag names from matches - .map(([, tagName]) => tagName) + // Could be formatting either a thought or a note (#3901) + const value = state.noteFocus + ? noteValue(state, state.cursor) + : getThoughtById(state, head(state.cursor))?.value + if (!value) return - /** Function to create a new text string with specified tags and their content removed. */ - const removeTags = (text: string, tags: string[]): string => - // Use reduce to accumulate a new string without the unwanted tags - tags.reduce((acc, tagName) => { - const openingTagPattern = new RegExp(`<${tagName}(\\s[^>]*)?>`, 'gi') - const closingTagPattern = new RegExp(``, 'gi') - // Replace both opening and closing tags with an empty string - return acc.replace(openingTagPattern, '').replace(closingTagPattern, '') - }, text) + const path = state.noteFocus ? resolveNotePath(state, state.cursor) : state.cursor + if (!path) return - dispatch((dispatch, getState) => { - const state = getState() - if (!state.cursor) return + // Use DOMParser to remove background-color and unwrap font/span tags that have no meaningful attributes + const doc = new DOMParser().parseFromString(value, 'text/html') - // Could be formatting either a thought or a note (#3901) - const value = state.noteFocus - ? noteValue(state, state.cursor) - : getThoughtById(state, head(state.cursor))?.value - if (!value) return + for (const el of Array.from(doc.body.querySelectorAll('font, span'))) { + // Remove background-color if it matches the default background color + if (el.style.backgroundColor && rgbToHex(el.style.backgroundColor) === rgbToHex(colors.bg)) { + el.style.removeProperty('background-color') + if (!el.getAttribute('style')?.trim()) { + el.removeAttribute('style') + } + } - const path = state.noteFocus ? resolveNotePath(state, state.cursor) : state.cursor - if (!path) return + // Remove color if it matches the default text color + if (el.style.color && rgbToHex(el.style.color) === rgbToHex(state.noteFocus ? colors.fg : colors.fgNote)) { + el.style.removeProperty('color') + } - const styleAttrPattern = /style\s*=\s*["'][^"']*["']/gi - const tagWithoutStylePattern = /<(span|font)(\s[^>]*)?>/gi + // Unwrap tags that have no meaningful style or color attributes + if (!el.getAttribute('style')?.trim() && !el.getAttribute('color')?.trim()) { + el.replaceWith(...Array.from(el.childNodes)) + } + } - //Replace style attributes based on the conditions - const styleRemovedThought = value.replace(styleAttrPattern, match => { - if (shouldRemoveStyle(match)) return '' - return match - }) - const tagsToRemove = collectTagsWithoutAttributes(styleRemovedThought, tagWithoutStylePattern) - const newValue = removeTags(styleRemovedThought, tagsToRemove) + const newValue = doc.body.innerHTML - // Overwrite the value of the thought or note with the stripped value in order to remove background highlighting (#3901) - if (newValue !== value) - dispatch( - state.noteFocus - ? setDescendant({ - path, - values: [newValue], - }) - : editThought({ - cursorOffset: selection.offsetThought() ?? undefined, - oldValue: value, - newValue: newValue, - path: simplifyPath(state, path), - // force the ContentEditable to update - force: true, - }), - ) - }) - } + // Overwrite the value of the thought or note with the stripped value in order to remove background highlighting (#3901) + if (newValue !== value) + dispatch( + state.noteFocus + ? setDescendant({ + path, + values: [newValue], + }) + : editThought({ + cursorOffset: selection.offsetThought() ?? undefined, + oldValue: value, + newValue: newValue, + path: simplifyPath(state, path), + // force the ContentEditable to update + force: true, + mergePrev: true, + }), + ) + }) } } diff --git a/src/actions/setDescendant.ts b/src/actions/setDescendant.ts index 8c59ff3c3d3..e9851b319ef 100644 --- a/src/actions/setDescendant.ts +++ b/src/actions/setDescendant.ts @@ -1,4 +1,5 @@ import _ from 'lodash' +import MergePrevActionPayload from '../@types/MergePrevActionPayload' import Path from '../@types/Path' import State from '../@types/State' import Thunk from '../@types/Thunk' @@ -11,11 +12,14 @@ import appendToPath from '../util/appendToPath' import createId from '../util/createId' import head from '../util/head' +interface setDescendantPayload extends MergePrevActionPayload { + path: Path + value?: string + values?: string[] +} + /** Sets a sequence of values as descendants. Preserves existing descendants and unrelated siblings, except for the last value, which always gets replaced by the given value. */ -const setDescendant = ( - state: State, - { path, value, values }: { path: Path; value?: string; values?: string[] }, -): State => { +const setDescendant = (state: State, { path, value, values }: setDescendantPayload): State => { // normalize values to array const _values = values || [value!] if (!value && (!values || values.length === 0)) return state diff --git a/src/commands/selectAll.ts b/src/commands/selectAll.ts index b0a3cb93f26..2bf4152ce66 100644 --- a/src/commands/selectAll.ts +++ b/src/commands/selectAll.ts @@ -50,7 +50,7 @@ const selectAllCommand: Command = { : addAllMulticursor({ // Hacky magic value, but it's the easiest way to tell the command that this is a chained gesture so that it can adjust the undo behavior. // Select All and the chained command need to be undone together, and this is not a property of the Command object but of the way it is invoked, so is somewhat appropriately stored on the event object, albeit ad hoc. - mergeUndo: e.type === 'chainedGesture', + mergeNext: e.type === 'chainedGesture', }), ) }, diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 2f2df0b87b6..f51e0a0acae 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -5,12 +5,9 @@ import { token } from '../../styled-system/tokens' import { formatSelectionActionCreator as formatSelection } from '../actions/formatSelection' import { isTouch } from '../browser' import { ColorToken } from '../colors.config' -import * as selection from '../device/selection' -import getThoughtById from '../selectors/getThoughtById' -import noteValue from '../selectors/noteValue' import themeColors from '../selectors/themeColors' +import batchEditingStore from '../stores/batchEditing' import commandStateStore from '../stores/commandStateStore' -import head from '../util/head' import rgbToHex from '../util/rgbToHex' import Popover from './Popover' import TextColorIcon from './icons/TextColor' @@ -48,47 +45,14 @@ const ColorSwatch: FC<{ size = size || fontSize * 1.2 const selected = useSelector(state => { - const currentEditableValue = - (!!state.cursor && - (state.noteFocus ? noteValue(state, state.cursor) : getThoughtById(state, head(state.cursor))?.value)) || - '' const themeColor = themeColors(state) - /* Define the color and background color regex to get the current color of current thought or note + /* Compare the swatch color to the command state color. document.execCommand('foreColor') adds the color attribute with hex and document.execCommand('backColor') adds the background-color attribute with the rgb document.execCommand('foreColor') always sets the color as hex whether the value is rgb or hex. And document.execCommand('backColor') always sets the background with the rgb */ - const colorRegex = /color="#([0-9a-fA-F]{6})"/g - const bgColorRegex = /background-color:\s*(rgb\(\d{1,3},\s?\d{1,3},\s?\d{1,3}\))/g const textHexColor = color ? addAlphaToHex(rgbToHex(themeColor[color])) : undefined const backHexColor = backgroundColor ? addAlphaToHex(rgbToHex(themeColor[backgroundColor])) : undefined - if ( - (!commandStateColor && !commandStateBackgroundColor) || - (commandStateColor === '#ccccccff' && commandStateBackgroundColor === '#333333ff') || - (commandStateColor === addAlphaToHex(rgbToHex(themeColor.fg)) && - commandStateBackgroundColor === addAlphaToHex(rgbToHex(themeColor.bg)) && - !selection.isThought()) - ) { - const colorMatches = currentEditableValue.match(colorRegex) || [] - let matchColor, match - // Get the colors and background colors used in current thought's value - const fgColors: Set = new Set() - if (colorMatches) { - // colorMatches will be like this : [color="#ee82ee", color="#ff823e"] and match.slice(7, -1) will be #ee82ee - // If the thought is colored with many colors, matchColor will be null and if the thought is colored with one color, matchColor will be that color - colorMatches.forEach(match => fgColors.add(match.slice(7, -1))) - matchColor = fgColors.size > 1 ? null : fgColors.values().next().value - } - - const bgColors: Set = new Set() - while ((match = bgColorRegex.exec(currentEditableValue)) !== null) if (match[1]) bgColors.add(match[1]) - const matchBgColor = bgColors.size > 1 ? null : bgColors.values().next().value - - return !!( - (textHexColor && textHexColor === (matchColor && addAlphaToHex(rgbToHex(matchColor)))) || - (backHexColor && backHexColor === (matchBgColor && addAlphaToHex(rgbToHex(matchBgColor)))) - ) - } return !!( (textHexColor && textHexColor === commandStateColor) || (backHexColor && backHexColor === commandStateBackgroundColor) @@ -108,8 +72,10 @@ const ColorSwatch: FC<{ ) }) + batchEditingStore.update(true) // Apply background color to the selection dispatch(formatSelection('backColor', selected ? 'bg' : (backgroundColor ?? 'bg'))) + batchEditingStore.update(false) } /** Toggles the text color onTouchEnd or onClick on desktop. */ diff --git a/src/components/ContentEditable.tsx b/src/components/ContentEditable.tsx index d86838ca368..0c7dc9eac91 100644 --- a/src/components/ContentEditable.tsx +++ b/src/components/ContentEditable.tsx @@ -106,6 +106,8 @@ const ContentEditable = React.memo( onKeyDown={(e: React.KeyboardEvent) => { if (props.onKeyDown) props.onKeyDown(e) }} + onTouchStart={() => console.info('ContentEditable onTouchStart')} + onTouchEnd={() => console.info('ContentEditable onTouchEnd')} /> ) }, diff --git a/src/components/Editable.tsx b/src/components/Editable.tsx index 55dc37d1da2..1d60713c691 100644 --- a/src/components/Editable.tsx +++ b/src/components/Editable.tsx @@ -40,6 +40,7 @@ import getSetting from '../selectors/getSetting' import getThoughtById from '../selectors/getThoughtById' import hasMulticursorSelector from '../selectors/hasMulticursor' import rootedParentOf from '../selectors/rootedParentOf' +import batchEditingStore from '../stores/batchEditing' import editingValueStore from '../stores/editingValue' import editingValueUntrimmedStore from '../stores/editingValueUntrimmed' import storageModel from '../stores/storageModel' @@ -284,6 +285,7 @@ const Editable = ({ // This will have no effect on useEditMode, which does not subscribe to state.cursorOffset reactively. cursorOffset: cursorOffset ?? selection.offsetThought() ?? undefined, force, + mergePrev: batchEditingStore.getState(), // If batch editing is in progress, merge this edit with the previous one in the undo stack. }), ) @@ -708,6 +710,8 @@ const Editable = ({ // unless it is given a hint that the element is some sort of form control role='button' style={style} + onTouchStart={() => console.info('Editable onTouchStart')} + onTouchEnd={() => console.info('Editable onTouchEnd')} /> ) } diff --git a/src/components/StaticThought.tsx b/src/components/StaticThought.tsx index 70e28db6d4c..c059ef27fb8 100644 --- a/src/components/StaticThought.tsx +++ b/src/components/StaticThought.tsx @@ -189,6 +189,8 @@ const StaticThought = ({ draggable={!!longPressProps && isSafari()} ref={isTouch ? dndRef(ref => dragSource(ref)) : undefined} style={{ minWidth: `${MIN_CONTENT_WIDTH_EM}em` }} + onTouchStart={() => console.info('StaticThought onTouchStart')} + onTouchEnd={() => console.info('StaticThought onTouchEnd')} > {homeContext ? ( // left, top are eyeballed for different font sizes diff --git a/src/components/ThoughtPositioner.tsx b/src/components/ThoughtPositioner.tsx index f79a4ad9177..4de61697784 100644 --- a/src/components/ThoughtPositioner.tsx +++ b/src/components/ThoughtPositioner.tsx @@ -25,7 +25,7 @@ const ThoughtPositioner = ({ className={css({ /* Use line-height to vertically center the text and bullet. We cannot use padding since it messes up the selection. This needs to be overwritten on multiline elements. See ".child .editable" below. */ /* must match value used in Editable useMultiline */ - lineHeight: '2', + lineHeight: '1.25', // ensure that ThoughtAnnotation is positioned correctly position: 'relative', ...(hideBullet ? { marginLeft: -12 } : null), diff --git a/src/components/ToolbarButton.tsx b/src/components/ToolbarButton.tsx index f5beb715e6b..4e64185ca2b 100644 --- a/src/components/ToolbarButton.tsx +++ b/src/components/ToolbarButton.tsx @@ -1,4 +1,4 @@ -import React, { FC, MutableRefObject, useCallback, useMemo, useRef, useState } from 'react' +import React, { FC, MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useSelector } from 'react-redux' import { css, cx } from '../../styled-system/css' import { toolbarPointerEventsRecipe } from '../../styled-system/recipes' @@ -14,7 +14,6 @@ import useDragAndDropToolbarButton from '../hooks/useDragAndDropToolbarButton' import useLongPress from '../hooks/useLongPress' import store from '../stores/app' import commandStateStore from '../stores/commandStateStore' -import dndRef from '../util/dndRef' import getCursorSortDirection from '../util/getCursorSortDirection' import haptics from '../util/haptics' @@ -54,6 +53,8 @@ const ToolbarButton: FC = ({ /** Tracks if mousedown occurred on this button, independent of React's render cycle. This prevents a race condition where the React-prop isPressing (derived from the parent Toolbar's pressingToolbarId state) hasn't updated between mousedown and click events dispatched in rapid succession (e.g. by Puppeteer under CI load). */ const isMouseDownRef = useRef(false) + const buttonRef = useRef(null) + const command = commandById(commandId) if (!command) { console.error('Missing command: ' + commandId) @@ -75,7 +76,7 @@ const ToolbarButton: FC = ({ const buttonError = useSelector(state => (!customize && command.error ? command.error(state) : null)) const isButtonExecutable = useSelector(state => customize || !canExecute || canExecute(state)) - const { isDragging, dragSource, isHovering, dropTarget } = useDragAndDropToolbarButton({ + const { isDragging, isHovering } = useDragAndDropToolbarButton({ commandId, customize, }) @@ -110,7 +111,8 @@ const ToolbarButton: FC = ({ /** Handles the onMouseUp/onTouchEnd event. Makes sure that we are actually clicking and not scrolling the toolbar. */ const tapUp = useCallback( - (e: React.MouseEvent | React.TouchEvent) => { + (e: TouchEvent) => { + console.info('tapUp tapUp') longPress.props[isTouch ? 'onTouchEnd' : 'onMouseUp']?.() const wasMouseDown = isMouseDownRef.current isMouseDownRef.current = false @@ -142,10 +144,6 @@ const ToolbarButton: FC = ({ } lastScrollLeft.current = toolbarEl.scrollLeft - - if (!disabled) { - onTapUp?.(commandId, e) - } }, // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -166,17 +164,15 @@ const ToolbarButton: FC = ({ /** Handles the onMouseDown/onTouchEnd event. Updates lastScrollPosition for tapUp. */ const tapDown = useCallback( - (e: React.MouseEvent | React.TouchEvent) => { + (e: TouchEvent) => { isMouseDownRef.current = true const iconEl = e.target as HTMLElement const toolbarEl = iconEl.closest('#toolbar')! - longPressTapDown?.(e) - + console.info('tapDown') lastScrollLeft.current = toolbarEl.scrollLeft if (!disabled) { haptics.medium() - onTapDown?.(commandId, e) } if (!customize && !isTouch) { @@ -201,12 +197,22 @@ const ToolbarButton: FC = ({ }), [buttonError, fontSize, isButtonActive, isButtonExecutable, isDragging], ) + + useEffect(() => { + const buttonEl = buttonRef.current + buttonRef.current?.addEventListener('touchstart', tapDown) + buttonRef.current?.addEventListener('touchend', tapUp) + return () => { + buttonEl?.removeEventListener('touchstart', tapDown) + buttonEl?.removeEventListener('touchend', tapUp) + } + }, [tapDown, tapUp]) return (
dragSource(dropTarget(node)))} + ref={buttonRef} key={commandId} title={`${command.label}${(command.keyboard ?? command.overlay?.keyboard) ? ` (${formatKeyboardShortcut((command.keyboard ?? command.overlay?.keyboard)!)})` : ''}${buttonError ? '\nError: ' + buttonError : ''}`} className={cx( @@ -245,10 +251,6 @@ const ToolbarButton: FC = ({ isMouseDownRef.current = false onMouseLeave?.() }} - onMouseDown={isTouch ? undefined : tapDown} - onClick={isTouch ? undefined : tapUp} - onTouchStart={isTouch ? tapDown : undefined} - onTouchEnd={isTouch ? tapUp : undefined} > { // selected top dash diff --git a/src/e2e/iOS/__tests__/caret.ts b/src/e2e/iOS/__tests__/caret.ts index 29e773dc8f0..dcaabcfd1d4 100644 --- a/src/e2e/iOS/__tests__/caret.ts +++ b/src/e2e/iOS/__tests__/caret.ts @@ -2,18 +2,10 @@ * IOS Safari caret positioning tests. * Uses WDIO test runner with Mocha framework. */ -import gestures from '../../../test-helpers/gestures' -import clickThought from '../helpers/clickThought' -import editThought from '../helpers/editThought' -import gesture from '../helpers/gesture' -import getEditable from '../helpers/getEditable' -import getEditingText from '../helpers/getEditingText' -import getElementRectByScreen from '../helpers/getElementRectByScreen' import getSelection from '../helpers/getSelection' import hideKeyboardByTappingDone from '../helpers/hideKeyboardByTappingDone' import isKeyboardShown from '../helpers/isKeyboardShown' import newThought from '../helpers/newThought' -import paste from '../helpers/paste' import tap from '../helpers/tap' import waitForEditable from '../helpers/waitForEditable' import waitUntil from '../helpers/waitUntil' @@ -34,221 +26,4 @@ describe('Caret', () => { const selectionTextContent = await getSelection().focusNode?.textContent expect(selectionTextContent).toBe('foo') }) - - it('Preserve Editing: true', async () => { - await newThought('foo') - await newThought('bar', { insertNewSubthought: true }) - - const editableNodeHandle = await waitForEditable('foo') - await tap(editableNodeHandle, { y: 60, x: 20 }) - - await waitUntil(async () => (await getEditingText()) === 'foo') - const selectionTextContent = await getSelection().focusNode?.textContent - expect(selectionTextContent).toBe('foo') - }) - - it('Preserve Editing: false', async () => { - await newThought('foo') - await newThought('bar', { insertNewSubthought: true }) - await hideKeyboardByTappingDone() - - const editableNodeHandle = await waitForEditable('foo') - await tap(editableNodeHandle) - - const selectionTextContent = await getSelection().focusNode?.textContent - expect(selectionTextContent).toBe(null) - }) - - it('No uncle loop', async () => { - const importText = ` - - a - - b - - c` - await newThought() - await paste([''], importText) - - await clickThought('b') - await newThought('d', { insertNewSubthought: true }) - - const editableNodeHandle = await waitForEditable('c') - await tap(editableNodeHandle, { y: 60, x: 20 }) - await waitUntil(async () => (await getEditingText()) === 'c') - - const selectionTextContent = await getSelection().focusNode?.textContent - expect(selectionTextContent).toBe('c') - }) - - it.skip('Tap hidden root thought', async () => { - const importText = ` - - a - - b - - c - - d` - await newThought() - await paste([''], importText) - await clickThought('a') - await clickThought('b') - await clickThought('c') - - const editableNodeHandle = await waitForEditable('d') - await tap(editableNodeHandle, { y: 60, x: 20 }) - await waitUntil(async () => (await getEditingText()) !== 'c') - - const editingText = await getEditingText() - expect(editingText).toBe('b') - }) - - it('Tap hidden uncle', async () => { - const importText = ` - - a - - b - - c - - d` - await newThought() - await paste([''], importText) - await clickThought('a') - await clickThought('b') - await clickThought('c') - - const editableNodeHandle = await waitForEditable('d') - await tap(editableNodeHandle, { y: 60, x: 20 }) - - await waitUntil(async () => (await getEditingText()) === 'd') - const selectionTextContent = await getSelection().focusNode?.textContent - expect(selectionTextContent).toBe('d') - }) - - it.skip('Tap empty content while keyboard up', async () => { - const importText = ` - - a - - b - - c - - d` - - await newThought() - await paste([''], importText) - await clickThought('b') - await clickThought('c') - - const editableNodeHandleD = await waitForEditable('d') - await tap(editableNodeHandleD, { x: 20, y: 200 }) - - // Wait until cursor change - await waitUntil(async () => (await getEditingText()) === 'b') - expect(await isKeyboardShown()).toBeTruthy() - const selectionTextContent = await getSelection().focusNode?.textContent - expect(selectionTextContent).toBe('b') - }) - - it.skip('Tap empty content while keyboard down', async () => { - const importText = ` - - a - - b - - c - - d` - - await newThought() - await paste([''], importText) - await clickThought('b') - await clickThought('c') - await hideKeyboardByTappingDone() - - const editableNodeHandleD = await waitForEditable('d') - await tap(editableNodeHandleD, { x: 20, y: 200 }) - - // Wait until cursor change - await waitUntil(async () => (await getEditingText()) === 'b') - expect(await isKeyboardShown()).toBeFalsy() - }) - - it('Swipe over cursor', async () => { - await newThought('foo') - await hideKeyboardByTappingDone() - - const editableNodeHandle = await waitForEditable('foo') - const elementRect = await getElementRectByScreen(editableNodeHandle) - - // swipe right on thought - await gesture('r', { - xStart: elementRect.x + 5, - yStart: elementRect.y + elementRect.height / 2, - segmentLength: elementRect.width, - }) - - await tap(editableNodeHandle, { y: 60, x: 20 }) - - const editingText = await getEditingText() - expect(editingText).toBe('foo') - - const selectionTextContent = await getSelection().focusNode?.textContent - expect(selectionTextContent).toBe(null) - }) - - it.skip('Swipe over hidden thought', async () => { - const importText = ` - - a - - x - - y - - b - - c - - d - - e - - f - - g - - h - - i` - - await newThought() - await paste([''], importText) - await waitForEditable('i') - await clickThought('a') - await clickThought('x') - await clickThought('y') - - const editableNodeHandle = await waitForEditable('y') - const elementRect = await getElementRectByScreen(editableNodeHandle) - - await gesture(gestures.newThought, { - xStart: elementRect.x + 5, - yStart: elementRect.y + elementRect.height + 10, - }) - await waitForEditable('') - - await editThought('this-is-new-thought') - const newThoughtEditable = await waitForEditable('this-is-new-thought') - - // get first child of parent thought - const previousSibling = await browser.execute((newThoughtEditable: HTMLElement) => { - const editable = (newThoughtEditable as unknown as HTMLElement) - .closest('ul.children') - ?.firstElementChild?.querySelector('[data-editable]') as HTMLElement - return editable?.innerText - }, newThoughtEditable) - - expect(previousSibling).toBe('y') - }) - - it.skip('Bump Thought Down on a thought that has children', async () => { - await newThought('foo') - await newThought('bar', { insertNewSubthought: true }) - await hideKeyboardByTappingDone() - - const editableNodeHandle = await getEditable('foo') - await tap(editableNodeHandle) - - await gesture(gestures.bumpThoughtDown) - const newThoughtEditable = await editThought('new') - const selectionTextContent = await getSelection().focusNode?.textContent - - const childrenTexts = await browser.execute((newThoughtEditable: HTMLElement) => { - const children = (newThoughtEditable as unknown as HTMLElement) - .closest('ul.children') - ?.firstElementChild?.getElementsByTagName('ul')[0] - ?.querySelectorAll('[data-editable]') as NodeListOf - return Array.from(children).map(x => (x as HTMLElement).innerText) - }, newThoughtEditable) - - expect(selectionTextContent).toBe('new') - expect(childrenTexts).toEqual(['foo', 'bar']) - }) }) diff --git a/src/e2e/iOS/__tests__/home.ts b/src/e2e/iOS/__tests__/home.ts deleted file mode 100644 index d0e41d7dcfc..00000000000 --- a/src/e2e/iOS/__tests__/home.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * IOS Safari home navigation tests. - * Uses WDIO test runner with Mocha framework. - */ -import $ from '../helpers/$' -import clickThought from '../helpers/clickThought' -import paste from '../helpers/paste' -import waitForEditable from '../helpers/waitForEditable' - -// test succeeds individually, but fails when there are too many tests running in parallel -// https://github.com/cybersemics/em/issues/1475 -// https://github.com/cybersemics/em/issues/1523 - -describe('Home', () => { - it('click home link to set the cursor to null', async () => { - const text = ` - - a - - b` - await paste(text) - await waitForEditable('b') - await clickThought('b') // set cursor - await clickThought('b') // open keyboard - - const editingBefore = await $('[data-editing=true]') - expect(editingBefore.elementId).toBeTruthy() - - const homeLink = await $('[data-testid=home] a') - expect(homeLink).toBeTruthy() - await homeLink.click() - - const editingAfter = await $('[data-editing=true]') - expect(editingAfter.elementId).toBeFalsy() - }) -}) diff --git a/src/e2e/iOS/__tests__/split.ts b/src/e2e/iOS/__tests__/split.ts deleted file mode 100644 index 757dfeff27b..00000000000 --- a/src/e2e/iOS/__tests__/split.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * IOS Safari thought splitting tests. - * Uses WDIO test runner with Mocha framework. - */ -import clickThought from '../helpers/clickThought' -import getEditingText from '../helpers/getEditingText' -import getSelection from '../helpers/getSelection' -import paste from '../helpers/paste' -import tap from '../helpers/tap' -import tapReturnKey from '../helpers/tapReturnKey' -import waitForEditable from '../helpers/waitForEditable' - -describe('Split', () => { - it('split a thought when the caret is in the middle', async () => { - const importText = ` - - puppeteer - - web scraping - - insomnia - - rest api` - - await paste(importText) - - await waitForEditable('puppeteer') - await clickThought('puppeteer') - - const editableNodeHandle = await waitForEditable('web scraping') - await clickThought('web scraping') - - await tap(editableNodeHandle, { y: 60, x: 25 }) - await tap(editableNodeHandle, { y: 60, x: 25 }) - await tapReturnKey() - - const offset = await getSelection()?.focusOffset - expect(offset).toBe(0) - - const editingText = await getEditingText() - expect(editingText).toBe('scraping') - }) -}) diff --git a/src/e2e/iOS/helpers/click.ts b/src/e2e/iOS/helpers/click.ts new file mode 100644 index 00000000000..5be8f63c3d8 --- /dev/null +++ b/src/e2e/iOS/helpers/click.ts @@ -0,0 +1,26 @@ +import waitForElement from './waitForElement' + +/** Click a node by selector or element with WebDriver click or synthetic DOM clicks. */ +const click = async (selector: string) => { + const el = await waitForElement(selector) + const rect = await browser.getElementRect(el.elementId) + + const centerX = rect.x + rect.width / 2 + const centerY = rect.y + rect.height / 2 + + await browser.performActions([ + { + type: 'pointer', + id: 'finger1', + parameters: { pointerType: 'mouse' }, + actions: [ + { type: 'pointerMove', duration: 0, x: Math.round(centerX), y: Math.round(centerY), origin: 'viewport' }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: 100 }, + { type: 'pointerUp', button: 0 }, + ], + }, + ]) +} + +export default click diff --git a/src/e2e/puppeteer/__tests__/color.ts b/src/e2e/puppeteer/__tests__/color.ts index dcd77083d73..c03679d262d 100644 --- a/src/e2e/puppeteer/__tests__/color.ts +++ b/src/e2e/puppeteer/__tests__/color.ts @@ -255,7 +255,7 @@ it('Clicking on a formatting tag does not close color dropdown', async () => { expect(textColorSwatch).toBeTruthy() }) -it('Set the background color of the note', async () => { +it('Toggle the background color of the note', async () => { await paste(` - a - =note @@ -266,10 +266,13 @@ it('Set the background color of the note', async () => { await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') await click('[aria-label="background color swatches"] [aria-label="green"]') - const result = await getFirstNoteText() - expect(result).toBe('Note') + const intermediate = await getFirstNoteText() + expect(intermediate).toBe('Note') await click('[aria-label="background color swatches"] [aria-label="green"]') + + const result = await getFirstNoteText() + expect(result).toBe('Note') }) it('Toggling note background color on and off should remove formatting tag', async () => { @@ -341,3 +344,45 @@ it('Can change the background color of a note to match its thought', async () => const note = await getFirstNoteText() expect(note).toBe('Note') }) + +it('Can change the color of a thought that already has the same color applied to part of its text', async () => { + await paste(` + - some formatted text + `) + + // change the color on the thought + await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') + await click('[aria-label="text color swatches"] [aria-label="red"]') + + const thought = await getEditingText() + expect(thought).toBe('some formatted text') +}) + +it('Can change the background color of a thought that already has the same background color applied to part of its text', async () => { + await paste(` + - some formatted text + `) + + // change the background color on the thought + await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') + await click('[aria-label="background color swatches"] [aria-label="red"]') + + const thought = await getEditingText() + expect(thought).toBe('some formatted text') +}) + +it('Can change the color of a note that already has the same color applied to part of its text', async () => { + await paste(` + - a + - =note + - some formatted text + `) + + // change the color on the note + await clickFirstNote() + await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') + await click('[aria-label="text color swatches"] [aria-label="red"]') + + const note = await getFirstNoteText() + expect(note).toBe('some formatted text') +}) diff --git a/src/e2e/puppeteer/__tests__/undo.ts b/src/e2e/puppeteer/__tests__/undo.ts index 30c1616e3b8..65bab390500 100644 --- a/src/e2e/puppeteer/__tests__/undo.ts +++ b/src/e2e/puppeteer/__tests__/undo.ts @@ -1,10 +1,12 @@ import { KnownDevices } from 'puppeteer' import newThoughtCommand from '../../../commands/newThought' +import click from '../helpers/click' import clickThought from '../helpers/clickThought' import exportThoughts from '../helpers/exportThoughts' import gesture from '../helpers/gesture' import getEditingText from '../helpers/getEditingText' import keyboard from '../helpers/keyboard' +import paste from '../helpers/paste' import press from '../helpers/press' import { page } from '../setup' @@ -31,7 +33,7 @@ it('Re-render cursor thought on undo', async () => { expect(thoughtValue).toBe('hello') }) -// We have to test this in puppeteer because chained commands are executed as separate commands at a higher level than action-creators and undone with an ad hoc mergeUndo property on the action. +// We have to test this in puppeteer because chained commands are executed as separate commands at a higher level than action-creators and undone with an ad hoc mergeNext property on the action. it('Undo Select All + Categorize chained command in one step', async () => { await page.emulate(KnownDevices['iPhone 15 Pro']) @@ -75,3 +77,62 @@ it('Undo Select All + Categorize chained command in one step', async () => { expect(highlightedCount).toBe(0) }) + +it('Should revert background color changes back to previous values', async () => { + const importText = ` + - Lorem Ipsum Dolor Sit Amet` + + await paste(importText) + + // open the ColorPicker + await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') + + const thought = await page.$('[aria-label=thought] [data-editable=true]') + const boundingBox = await thought?.boundingBox() + + if (!boundingBox) throw new Error('boundingBox not found') + + const y = boundingBox.y + boundingBox.height / 2 + + // get a position near the left edge of the thought + const leftX = boundingBox.x + 1 + + // double click to select the first word + await page.mouse.click(leftX, y, { clickCount: 2 }) + + // set the first word's background color to green + await click('[aria-label="background color swatches"] [aria-label="green"]') + + // dismiss the existing selection range + await page.mouse.click(leftX, y) + + // get a position near the right edge of the thought + const rightX = boundingBox.x + boundingBox.width - 36 + + // double click to select the last word + await page.mouse.click(rightX, y, { clickCount: 2 }) + + // set the last word's background color to green + await click('[aria-label="background color swatches"] [aria-label="green"]') + + // dismiss the existing selection range + await page.mouse.click(rightX, y) + + // get a position at the center of the thought + const centerX = boundingBox.x + boundingBox.width / 2 + + // click to place the caret in the center of the thought + await page.mouse.click(centerX, y) + + // set the entire thought's background color to red + await click('[aria-label="background color swatches"] [aria-label="red"]') + + // undo + await press('z', { meta: true }) + + // now the first and last words should have a green background again + const text = await getEditingText() + expect(text).toBe( + 'Lorem Ipsum Dolor Sit Amet', + ) +}) diff --git a/src/redux-enhancers/undoRedoEnhancer.ts b/src/redux-enhancers/undoRedoEnhancer.ts index 45ef23bf5a2..ddc6182d1f7 100644 --- a/src/redux-enhancers/undoRedoEnhancer.ts +++ b/src/redux-enhancers/undoRedoEnhancer.ts @@ -39,6 +39,25 @@ function isSetIsMulticursorExecutingAction(action: Action): action is Se function isEditThoughtAction(action: UnknownAction): action is UnknownAction & editThoughtPayload { return action.type === 'editThought' } + +/** Compare the text contents of the old and new values to determine the direction of the edit. + * Returns None if the action is not an editThought action or if the text content length is the same. + */ +function getEditThoughtDirection(action: UnknownAction): EditThoughtDirection { + if (!isEditThoughtAction(action)) return EditThoughtDirection.None + + const oldElement = document.createElement('div') + const newElement = document.createElement('div') + oldElement.innerHTML = action.oldValue + newElement.innerHTML = action.newValue + + return newElement.textContent.length === oldElement.textContent.length + ? EditThoughtDirection.None + : newElement.textContent.length > oldElement.textContent.length + ? EditThoughtDirection.Longer + : EditThoughtDirection.Shorter +} + /** Properties that are ignored when generating state patches. */ const statePropertiesToOmit: (keyof State)[] = ['alert', 'cursorCleared', 'editableNonce', 'pushQueue'] @@ -252,11 +271,10 @@ const undoRedoReducerEnhancer: StoreEnhancer = } // Determine if an edit is an addition or a deletion - const editThoughtDirection = isEditThoughtAction(action) - ? action.newValue.length > action.oldValue.length - ? EditThoughtDirection.Longer - : EditThoughtDirection.Shorter - : EditThoughtDirection.None + const editThoughtDirection = getEditThoughtDirection(action) + + const shouldMergeWithLastEditThought = + editThoughtDirection !== EditThoughtDirection.None && editThoughtDirection === lastEditThoughtDirection // Some actions are merged together into a single undo/redo patch. // - Navigation actions are merged with the previous non-navigation action. This matches the behavior of most word processors where undo will revert the last destructive action, and the cursor will be restored to where it was before. For example, if the user edits 'a' to 'aa', moves the cursor to 'b', and then undoes, the cursor will be restored to 'aa' then the edit will be undone. @@ -266,10 +284,11 @@ const undoRedoReducerEnhancer: StoreEnhancer = // - Chained commands will be merged into the previous command, e.g. Select All + Categorize if ( (isNavigation(actionType) && isNavigation(lastAction?.type)) || - (actionType === 'editThought' && editThoughtDirection === lastEditThoughtDirection) || + shouldMergeWithLastEditThought || actionType === 'closeAlert' || state.isMulticursorExecuting || - (lastAction as UnknownAction)?.mergeUndo + (lastAction as UnknownAction)?.mergeNext || + (action as UnknownAction)?.mergePrev ) { lastAction = action const lastUndoPatch = nthLast(state.undoPatches, 1) diff --git a/src/stores/batchEditing.ts b/src/stores/batchEditing.ts new file mode 100644 index 00000000000..262d9d75a3d --- /dev/null +++ b/src/stores/batchEditing.ts @@ -0,0 +1,7 @@ +import ministore from './ministore' + +/** A store that signals that multiple execCommand operations are in progress and should be treated as a single edit. While true, editThought actions + * dispatched by thoughtChangeHandler in Editable have their mergePrev property set to true so that they will be merged in undoRedoEnhancer (#3904). */ +const batchEditing = ministore(false) + +export default batchEditing diff --git a/src/util/__tests__/getCommandState.ts b/src/util/__tests__/getCommandState.ts index e72b5ad1ec9..6b91351f485 100644 --- a/src/util/__tests__/getCommandState.ts +++ b/src/util/__tests__/getCommandState.ts @@ -135,6 +135,22 @@ it.skip('text and background color on span tag', () => { }) }) +it('partially styled thought with color and background color', () => { + expect( + getCommandState( + 'text but only partly', + ), + ).toStrictEqual({ + bold: true, + italic: true, + underline: true, + strikethrough: true, + code: true, + foreColor: undefined, + backColor: undefined, + }) +}) + it('fully styled thought', () => { expect( getCommandState( @@ -166,3 +182,33 @@ it('fully styled thought without text content', () => { backColor: 'rgb(0, 0, 255)', }) }) + +it('nested colors should use the inner one', () => { + expect(getCommandState('text')).toStrictEqual( + { + bold: false, + italic: false, + underline: false, + strikethrough: false, + code: false, + foreColor: 'rgb(0, 0, 255)', + backColor: undefined, + }, + ) +}) + +it('nested background colors should use the inner one', () => { + expect( + getCommandState( + 'text', + ), + ).toStrictEqual({ + bold: false, + italic: false, + underline: false, + strikethrough: false, + code: false, + foreColor: undefined, + backColor: 'rgb(0, 0, 255)', + }) +}) diff --git a/src/util/getCommandState.ts b/src/util/getCommandState.ts index 896c6aafa7c..74da8968ea0 100644 --- a/src/util/getCommandState.ts +++ b/src/util/getCommandState.ts @@ -23,19 +23,22 @@ const tags = { /** Extracts the foreground and background colors from the given string. * Returns an object with foreColor and backColor properties. - * If the string does not contain a font or span tag, undefined is returned. + * A color is only returned if its span or font tag wraps all of the text in savedValue (#3904). + * Otherwise, undefined is returned. */ -const extractColors = (savedValue: string) => { - const foreColorRegex = /<(?:span|font)[^>]*\s(?:color=["']?([^"']+)["']?[^>]*>)/i - const backColorRegex = /<(?:span|font)[^>]*\sstyle=["'][^"']*background-color:\s*([^;"']+)/i +const extractColors = (savedValue: string): { foreColor: string | undefined; backColor: string | undefined } => { + const doc = new DOMParser().parseFromString(savedValue, 'text/html') + const tags = Array.from(doc.body.querySelectorAll('span, font')).filter( + el => el.textContent === doc.body.textContent, + ) - // Attempt to extract the font color - const foreColorMatch = savedValue.match(foreColorRegex) - const foreColor = foreColorMatch ? foreColorMatch[1].trim() : undefined + let foreColor: string | undefined + let backColor: string | undefined - // Attempt to extract the background-color from span - const backColorMatch = savedValue.match(backColorRegex) - const backColor = backColorMatch ? backColorMatch[1].trim() : undefined + for (const el of tags) { + foreColor = el.getAttribute('color') || el.style.color || foreColor + backColor = el.style.backgroundColor || backColor + } return { foreColor, backColor } }