diff --git a/.github/workflows/analyze.yml b/.github/workflows/analyze.yml index 572f659..54c19b7 100644 --- a/.github/workflows/analyze.yml +++ b/.github/workflows/analyze.yml @@ -1,60 +1,140 @@ -name: Static Analysis +name: Pre-flight Checks + +# ───────────────────────────────────────────────────────────────────────────── +# Pipeline 1 — Pre-flight (target: < 2 min) +# +# Runs on every push / PR as the fast gate that must pass before any heavy +# job is allowed to start. Three responsibilities: +# +# 1. dart format — code style, fails on any formatting diff +# 2. flutter analyze --fatal-infos — zero errors/warnings/infos enforced +# 3. Changed-path detection — outputs which packages were touched so that +# downstream jobs (test.yml) know what to test +# +# Optimisations vs the old setup: +# • Single runner (ubuntu-22.04), single Flutter version — no duplication +# • Skips ALL checks when only *.md / doc/ / .github/ files changed +# • pub-cache restored from cache before `flutter pub get` +# ───────────────────────────────────────────────────────────────────────────── + +env: + FLUTTER_VERSION: "3.29.2" on: push: - branches: [ main, develop ] + branches: [main, develop] pull_request: - branches: [ main, develop ] + branches: [main, develop] jobs: - analyze: - name: Analyze Code - runs-on: ubuntu-latest + preflight: + name: Format · Analyze · Path-filter + runs-on: ubuntu-22.04 + + outputs: + # Which packages (or groups) have changed — consumed by test.yml + changed_core: ${{ steps.filter.outputs.core }} + changed_html: ${{ steps.filter.outputs.html }} + changed_markdown: ${{ steps.filter.outputs.markdown }} + changed_highlight: ${{ steps.filter.outputs.highlight }} + changed_clipboard: ${{ steps.filter.outputs.clipboard }} + changed_devtools: ${{ steps.filter.outputs.devtools }} + changed_root: ${{ steps.filter.outputs.root }} + changed_any_dart: ${{ steps.filter.outputs.any_dart }} steps: - - name: Checkout code + - name: Checkout uses: actions/checkout@v4 + # ── Detect changed paths ──────────────────────────────────────────────── + - name: Detect changed paths + id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + core: + - 'packages/hyper_render_core/**' + html: + - 'packages/hyper_render_html/**' + markdown: + - 'packages/hyper_render_markdown/**' + highlight: + - 'packages/hyper_render_highlight/**' + clipboard: + - 'packages/hyper_render_clipboard/**' + devtools: + - 'packages/hyper_render_devtools/**' + root: + - 'lib/**' + - 'test/**' + - 'pubspec.yaml' + - 'pubspec.lock' + any_dart: + - '**/*.dart' + - '**/pubspec.yaml' + - '**/pubspec.lock' + - '**/analysis_options.yaml' + + # ── Skip everything if only docs / CI config changed ─────────────────── + - name: Skip if docs-only change + if: steps.filter.outputs.any_dart == 'false' + run: | + echo "✓ Only non-Dart files changed — skipping format & analyze" + echo " (golden.yml and benchmark.yml handle their own triggers)" + + # ── Flutter setup (only when Dart files changed) ──────────────────────── - name: Setup Flutter + if: steps.filter.outputs.any_dart == 'true' uses: subosito/flutter-action@v2 with: + flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable cache: true - - name: Get dependencies + - name: Restore pub cache + if: steps.filter.outputs.any_dart == 'true' + uses: actions/cache@v4 + with: + path: | + ~/.pub-cache + ${{ env.PUB_CACHE }} + key: pub-ubuntu-${{ hashFiles('**/pubspec.lock') }} + restore-keys: pub-ubuntu- + + - name: flutter pub get + if: steps.filter.outputs.any_dart == 'true' run: flutter pub get - - name: Run flutter analyze - run: flutter analyze --no-pub > analyze_report.txt || true + # ── dart format ───────────────────────────────────────────────────────── + - name: dart format (fail on diff) + if: steps.filter.outputs.any_dart == 'true' + run: dart format --set-exit-if-changed . - - name: Check for errors + # ── flutter analyze ───────────────────────────────────────────────────── + - name: flutter analyze --fatal-infos + if: steps.filter.outputs.any_dart == 'true' run: | - errors=$(grep -c "error •" analyze_report.txt || true) - warnings=$(grep -c "warning •" analyze_report.txt || true) - infos=$(grep -c "info •" analyze_report.txt || true) - - echo "Static Analysis Results:" - echo " Errors: $errors" - echo " Warnings: $warnings" - echo " Infos: $infos" + flutter analyze --no-pub --fatal-infos 2>&1 | tee analyze_report.txt + EXIT=${PIPESTATUS[0]} - # Fail if there are errors - if [ "$errors" -gt 0 ]; then - echo "❌ Found $errors errors" - cat analyze_report.txt - exit 1 - fi + ERRORS=$(grep -c "error •" analyze_report.txt 2>/dev/null || true) + WARNS=$(grep -c "warning •" analyze_report.txt 2>/dev/null || true) + INFOS=$(grep -c "info •" analyze_report.txt 2>/dev/null || true) - # Warn if there are many warnings - if [ "$warnings" -gt 20 ]; then - echo "⚠️ High number of warnings: $warnings" - fi + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " Static Analysis Results" + echo " Errors: $ERRORS" + echo " Warnings: $WARNS" + echo " Infos: $INFOS" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "✅ Static analysis passed" + exit $EXIT - name: Upload analyze report - if: always() + if: always() && steps.filter.outputs.any_dart == 'true' uses: actions/upload-artifact@v4 with: - name: analyze-report + name: analyze-report-${{ github.run_number }} path: analyze_report.txt + retention-days: 7 diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 0187101..f0b8bae 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -1,63 +1,190 @@ -name: Benchmarks +name: Performance Regression -on: - # Run benchmarks weekly - schedule: - - cron: '0 0 * * 0' # Every Sunday at midnight UTC +# ───────────────────────────────────────────────────────────────────────────── +# Two jobs: +# +# layout-regression — runs on every PR and every push to main. +# • Executes benchmark/layout_regression.dart +# • Fails if any fixture's median layout time exceeds its hard threshold +# • Posts a PR comment with the full results table +# +# full-benchmark — runs weekly + on release branches. +# • Executes the original benchmark/parse_benchmark.dart (throughput info) +# • Never fails CI (informational only) — results are uploaded as artifacts +# ───────────────────────────────────────────────────────────────────────────── - # Allow manual trigger - workflow_dispatch: +env: + FLUTTER_VERSION: "3.29.2" # keep in sync with golden.yml - # Run on release branches +on: + pull_request: + branches: [main, develop] push: - branches: - - 'release/**' + branches: [main] + schedule: + - cron: '0 0 * * 0' # weekly full benchmark (Sunday midnight UTC) + workflow_dispatch: jobs: - benchmark: - name: Run Performance Benchmarks - runs-on: ubuntu-latest + # ── Layout regression guard (runs on every PR) ──────────────────────────── + layout-regression: + name: Layout Regression (60 FPS guard) + runs-on: ubuntu-22.04 + # Skip on the weekly schedule — that's for the full-benchmark job only + if: github.event_name != 'schedule' steps: - - name: Checkout code + - name: Checkout uses: actions/checkout@v4 - - name: Setup Flutter + - name: Setup Flutter (pinned) uses: subosito/flutter-action@v2 with: + flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable cache: true - name: Get dependencies run: flutter pub get - - name: Run benchmarks + - name: Run layout regression benchmark + id: bench run: | - echo "Running benchmarks..." - flutter test benchmark/ --no-test-randomize-ordering-seed + mkdir -p benchmark/results - - name: Save benchmark results - run: | - mkdir -p benchmark_results - echo "Date: $(date)" > benchmark_results/latest.txt - echo "Commit: ${{ github.sha }}" >> benchmark_results/latest.txt - echo "" >> benchmark_results/latest.txt - echo "See test output above for detailed results" >> benchmark_results/latest.txt + # Run with JSON reporter so we can parse pass/fail + flutter test benchmark/layout_regression.dart \ + --reporter expanded \ + 2>&1 | tee benchmark/results/ci_run.txt - - name: Upload benchmark results + # Extract overall pass/fail from test exit code + EXIT_CODE=${PIPESTATUS[0]} + + # Parse the JSON result files for the summary table + SUMMARY=$(python3 - <<'PYEOF' + import json, os, glob + + files = sorted(glob.glob('benchmark/results/layout_*.json')) + if not files: + print("No result file generated.") + else: + data = json.load(open(files[-1])) + rows = [] + any_fail = False + for r in data.get('results', []): + icon = "✅" if r["passed"] else "❌" + rows.append( + f"| {icon} | `{r['fixture']}` | {r['threshold_ms']} | " + f"{r['median_ms']} | {r['p95_ms']} |" + ) + if not r["passed"]: + any_fail = True + + header = ( + "| | Fixture | Budget (ms) | Median (ms) | P95 (ms) |\n" + "|---|---|---|---|---|" + ) + print(header) + print("\n".join(rows)) + if any_fail: + print("\n**One or more fixtures exceeded the 16 ms budget.**") + PYEOF + ) + + echo "summary<> "$GITHUB_OUTPUT" + echo "$SUMMARY" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT" + + exit $EXIT_CODE + + - name: Upload result JSON + if: always() uses: actions/upload-artifact@v4 with: - name: benchmark-results - path: benchmark_results/ + name: layout-regression-${{ github.run_number }} + path: benchmark/results/ + retention-days: 30 - - name: Comment on PR (if applicable) - if: github.event_name == 'pull_request' + - name: Post PR comment + if: always() && github.event_name == 'pull_request' uses: actions/github-script@v7 with: script: | - github.rest.issues.createComment({ - issue_number: context.issue.number, + const exitCode = '${{ steps.bench.outputs.exit_code }}'; + const summary = `${{ steps.bench.outputs.summary }}`; + const passed = exitCode === '0'; + const icon = passed ? '✅' : '❌'; + const headline = passed + ? '## ✅ Layout Regression — All fixtures within 60 FPS budget' + : '## ❌ Layout Regression — Budget exceeded'; + + const body = [ + headline, + '', + summary, + '', + `> Flutter \`${{ env.FLUTTER_VERSION }}\` · ubuntu-22.04`, + '', + passed + ? '_No action required._' + : [ + '**Action required:** a layout fixture exceeded its millisecond', + 'budget. Profile the regression with:', + '```bash', + 'flutter test benchmark/layout_regression.dart --reporter expanded', + '```', + 'and check `_performLineLayout` / `_buildCharacterMapping` for', + 'any new O(N²) or O(N log N) paths introduced in this PR.', + ].join('\n'), + ].join('\n'); + + await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, - body: '🏎️ Benchmark tests completed. Check the Actions tab for detailed results.' - }) + issue_number: context.issue.number, + body, + }); + + # ── Full throughput benchmark (weekly, informational) ──────────────────── + full-benchmark: + name: Full Throughput Benchmark + runs-on: ubuntu-22.04 + if: >- + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + startsWith(github.ref, 'refs/heads/release/') + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Flutter (pinned) + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache: true + + - name: Get dependencies + run: flutter pub get + + - name: Run parse benchmarks + run: | + flutter test benchmark/parse_benchmark.dart \ + --no-test-randomize-ordering-seed \ + --reporter expanded \ + 2>&1 | tee benchmark/results/parse_$(date +%Y%m%d).txt + + - name: Run layout regression (informational — never fails here) + run: | + flutter test benchmark/layout_regression.dart \ + --reporter expanded \ + 2>&1 | tee benchmark/results/layout_$(date +%Y%m%d).txt || true + + - name: Upload benchmark results + uses: actions/upload-artifact@v4 + with: + name: benchmark-full-${{ github.run_number }} + path: benchmark/results/ + retention-days: 90 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 10feb9d..9fcc649 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,15 +1,47 @@ name: Coverage +# ───────────────────────────────────────────────────────────────────────────── +# Pipeline 2 (continuation) — Coverage report +# +# Runs only on push to main (not on PRs) to keep PR feedback fast. +# Skipped entirely when no Dart files changed (docs-only push). +# +# Pinned to the same runner + Flutter version as Pipeline 1 & 2 so that +# coverage numbers are produced in a reproducible environment. +# ───────────────────────────────────────────────────────────────────────────── + +env: + FLUTTER_VERSION: "3.29.2" + on: push: - branches: [ main ] - pull_request: - branches: [ main ] + branches: [main] jobs: + # ── Detect changed paths ─────────────────────────────────────────────────── + path-filter: + name: Detect Changed Packages + runs-on: ubuntu-22.04 + outputs: + changed_any_dart: ${{ steps.filter.outputs.any_dart }} + steps: + - uses: actions/checkout@v4 + - id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + any_dart: + - '**/*.dart' + - '**/pubspec.yaml' + - '**/pubspec.lock' + - '**/analysis_options.yaml' + + # ── Coverage report ──────────────────────────────────────────────────────── coverage: name: Generate Coverage - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 + needs: path-filter + if: needs.path-filter.outputs.changed_any_dart == 'true' steps: - name: Checkout code @@ -18,14 +50,24 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2 with: + flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable cache: true - - name: Install dependencies + - name: Restore pub cache + uses: actions/cache@v4 + with: + path: | + ~/.pub-cache + ${{ env.PUB_CACHE }} + key: pub-ubuntu-${{ hashFiles('**/pubspec.lock') }} + restore-keys: pub-ubuntu- + + - name: flutter pub get run: flutter pub get - name: Run tests with coverage - run: flutter test --coverage + run: flutter test --no-pub --coverage - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 @@ -36,7 +78,7 @@ jobs: - name: Check coverage threshold run: | - sudo apt-get install -y lcov + sudo apt-get install -y --no-install-recommends lcov total=$(lcov --summary coverage/lcov.info 2>&1 | grep "lines" | awk '{print $2}' | cut -d'%' -f1) echo "Coverage: $total%" @@ -48,11 +90,11 @@ jobs: fi - name: Generate HTML report - run: | - genhtml coverage/lcov.info -o coverage/html + run: genhtml coverage/lcov.info -o coverage/html - name: Upload HTML report uses: actions/upload-artifact@v4 with: - name: coverage-report + name: coverage-report-${{ github.run_number }} path: coverage/html + retention-days: 30 diff --git a/.github/workflows/golden.yml b/.github/workflows/golden.yml index 8a84074..06b77cc 100644 --- a/.github/workflows/golden.yml +++ b/.github/workflows/golden.yml @@ -1,23 +1,61 @@ name: Visual Regression +# ───────────────────────────────────────────────────────────────────────────── +# Golden-test stability contract +# +# All jobs MUST run on the SAME pinned runner image and the SAME Flutter +# version so that font metrics, sub-pixel rounding, and Skia rasterisation +# are byte-identical between the machine that generated the reference PNGs +# and the machine that compares against them. +# +# Pinning strategy: +# • runs-on: ubuntu-22.04 — exact LTS release, never rolls +# • flutter-version: — exact Flutter SDK tag +# • Noto font family installed — consistent CJK / Arabic / Latin glyphs +# +# To update the Flutter pin: bump FLUTTER_VERSION here AND re-run +# the "Update Goldens" workflow_dispatch job so references are regenerated +# on the new version before the pin is merged. +# ───────────────────────────────────────────────────────────────────────────── + +env: + FLUTTER_VERSION: "3.29.2" # ← single source of truth; bump here + update-goldens + FONT_PACKAGES: >- + fonts-noto + fonts-noto-cjk + fonts-noto-color-emoji + fonts-roboto + on: pull_request: branches: [main, develop] push: branches: [main] + workflow_dispatch: # manual trigger for update-goldens job jobs: + # ── Compare against stored references ────────────────────────────────────── golden: name: Golden Tests - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 # pinned — do NOT change to ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - - name: Setup Flutter + - name: Install pinned Noto fonts + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends ${{ env.FONT_PACKAGES }} + # Rebuild font cache so Flutter picks up the newly installed fonts + fc-cache -fv + echo "Installed fonts:" + fc-list | grep -E "Noto|Roboto" | sort + + - name: Setup Flutter (pinned version) uses: subosito/flutter-action@v2 with: + flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable cache: true @@ -29,15 +67,19 @@ jobs: run: | flutter test test/golden/ \ --reporter json \ - > golden_results.json 2>&1 || true + 2>&1 | tee golden_results.json || true + + FAILURES=$(python3 -c " + import json, sys + data = open('golden_results.json').read() + errors = data.count('\"result\":\"error\"') + print(errors) + " 2>/dev/null || echo 0) - # Count failures - FAILURES=$(grep -c '"result":"error"' golden_results.json 2>/dev/null || echo 0) echo "failures=$FAILURES" >> "$GITHUB_OUTPUT" - # Exit with error if there were failures if [ "$FAILURES" -gt "0" ]; then - echo "::error::$FAILURES golden test(s) failed" + echo "::error::$FAILURES golden test(s) failed — pixel mismatch detected" exit 1 fi @@ -57,6 +99,7 @@ jobs: with: script: | const failures = '${{ steps.golden.outputs.failures }}'; + const flutterVersion = process.env.FLUTTER_VERSION; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, @@ -64,25 +107,34 @@ jobs: body: [ '## ❌ Visual Regression Detected', '', - `**${failures} golden test(s) failed.**`, + `**${failures} golden test(s) failed** on Flutter ${flutterVersion} / ubuntu-22.04.`, '', 'The rendered output no longer matches the reference images.', '', - '**If the change is intentional**, regenerate the goldens locally:', + '**If the change is intentional**, regenerate the goldens on the', + 'same platform via the _Update Goldens_ workflow dispatch, or run', + 'locally in Docker:', '```bash', - 'flutter test test/golden/ --update-goldens', + 'docker run --rm \\', + ' -v $(pwd):/workspace -w /workspace \\', + ' ghcr.io/cirruslabs/flutter:' + flutterVersion + ' \\', + ' bash -c "apt-get update -qq && \\', + ' apt-get install -y fonts-noto fonts-noto-cjk fonts-roboto && \\', + ' flutter pub get && \\', + ' flutter test test/golden/ --update-goldens"', 'git add test/golden/goldens/', - 'git commit -m "chore: update golden references"', + 'git commit -m "chore: update golden references (Flutter ' + flutterVersion + ')"', '```', '', - 'Download the diff artifacts from this workflow run to inspect changes.', + '> ⚠️ Always regenerate goldens on **ubuntu-22.04** with **Flutter', + `> ${flutterVersion}** to keep references pixel-stable across machines.`, ].join('\n'), }); - # Separate job: update goldens on direct push to main (manual trigger) + # ── Regenerate reference images (manual workflow_dispatch only) ──────────── update-goldens: name: Update Goldens (manual) - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 # MUST match the comparison job above if: github.event_name == 'workflow_dispatch' steps: @@ -91,9 +143,16 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} - - name: Setup Flutter + - name: Install pinned Noto fonts + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends ${{ env.FONT_PACKAGES }} + fc-cache -fv + + - name: Setup Flutter (pinned version) uses: subosito/flutter-action@v2 with: + flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable cache: true @@ -108,5 +167,6 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add test/golden/goldens/ - git diff --staged --quiet || git commit -m "chore: regenerate golden references [skip ci]" + git diff --staged --quiet || \ + git commit -m "chore: regenerate golden references (Flutter ${{ env.FLUTTER_VERSION }}) [skip ci]" git push diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 37dec72..baddb8e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,47 +1,195 @@ -name: Tests +name: Core Validation + +# ───────────────────────────────────────────────────────────────────────────── +# Pipeline 2 — Core Validation (target: < 5 min on PR) +# +# Two jobs, gated by a shared path-filter: +# +# test-pr — PR trigger only. +# Single runner (ubuntu-22.04, pinned Flutter). +# Only the packages that changed are tested, so a one-line +# fix in hyper_render_html doesn't re-run core/markdown/etc. +# +# test-matrix — Push to main/develop only. +# Full 3-OS × 2-channel matrix to catch platform regressions +# before the tag lands. +# +# What this job does NOT do (handled by Pipeline 1 — analyze.yml): +# • dart format +# • flutter analyze +# ───────────────────────────────────────────────────────────────────────────── + +env: + FLUTTER_VERSION: "3.29.2" on: push: - branches: [ main, develop ] + branches: [main, develop] pull_request: - branches: [ main, develop ] + branches: [main, develop] jobs: - test: - name: Run Tests + # ── Changed-path detection ───────────────────────────────────────────────── + path-filter: + name: Detect Changed Packages + runs-on: ubuntu-22.04 + outputs: + changed_core: ${{ steps.filter.outputs.core }} + changed_html: ${{ steps.filter.outputs.html }} + changed_markdown: ${{ steps.filter.outputs.markdown }} + changed_highlight: ${{ steps.filter.outputs.highlight }} + changed_clipboard: ${{ steps.filter.outputs.clipboard }} + changed_devtools: ${{ steps.filter.outputs.devtools }} + changed_root: ${{ steps.filter.outputs.root }} + changed_any_dart: ${{ steps.filter.outputs.any_dart }} + steps: + - uses: actions/checkout@v4 + - id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + core: + - 'packages/hyper_render_core/**' + html: + - 'packages/hyper_render_html/**' + markdown: + - 'packages/hyper_render_markdown/**' + highlight: + - 'packages/hyper_render_highlight/**' + clipboard: + - 'packages/hyper_render_clipboard/**' + devtools: + - 'packages/hyper_render_devtools/**' + root: + - 'lib/**' + - 'test/**' + - 'pubspec.yaml' + - 'pubspec.lock' + any_dart: + - '**/*.dart' + - '**/pubspec.yaml' + - '**/pubspec.lock' + - '**/analysis_options.yaml' + + # ── PR: fast single-OS run — only changed packages ───────────────────────── + test-pr: + name: Tests (PR · ubuntu-22.04 · stable) + runs-on: ubuntu-22.04 + needs: path-filter + if: >- + github.event_name == 'pull_request' && + needs.path-filter.outputs.changed_any_dart == 'true' + + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache: true + + - name: Restore pub cache + uses: actions/cache@v4 + with: + path: | + ~/.pub-cache + ${{ env.PUB_CACHE }} + key: pub-ubuntu-${{ hashFiles('**/pubspec.lock') }} + restore-keys: pub-ubuntu- + + - name: flutter pub get + run: flutter pub get + + # Root package — always run when root lib/test or pubspec changed. + # Also covers tests that exercise the core package from the root. + - name: Test root package + if: >- + needs.path-filter.outputs.changed_root == 'true' || + needs.path-filter.outputs.changed_core == 'true' + run: flutter test --no-pub + + - name: Test hyper_render_core + if: needs.path-filter.outputs.changed_core == 'true' + working-directory: packages/hyper_render_core + run: flutter test --no-pub + + - name: Test hyper_render_html + if: >- + needs.path-filter.outputs.changed_html == 'true' || + needs.path-filter.outputs.changed_core == 'true' + working-directory: packages/hyper_render_html + run: flutter test --no-pub + + - name: Test hyper_render_markdown + if: >- + needs.path-filter.outputs.changed_markdown == 'true' || + needs.path-filter.outputs.changed_core == 'true' + working-directory: packages/hyper_render_markdown + run: flutter test --no-pub + + - name: Test hyper_render_highlight + if: >- + needs.path-filter.outputs.changed_highlight == 'true' || + needs.path-filter.outputs.changed_core == 'true' + working-directory: packages/hyper_render_highlight + run: flutter test --no-pub + + - name: Test hyper_render_clipboard + if: >- + needs.path-filter.outputs.changed_clipboard == 'true' || + needs.path-filter.outputs.changed_core == 'true' + working-directory: packages/hyper_render_clipboard + run: flutter test --no-pub + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results-pr-${{ github.run_number }} + path: | + test-results/ + **/test-results/ + retention-days: 7 + + # ── Push to main/develop: full 3-OS × 2-channel matrix ──────────────────── + test-matrix: + name: Tests (${{ matrix.os }} · ${{ matrix.flutter-channel }}) runs-on: ${{ matrix.os }} + needs: path-filter + if: >- + github.event_name == 'push' && + needs.path-filter.outputs.changed_any_dart == 'true' strategy: + fail-fast: false # let all matrix legs complete so we see the full picture matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-22.04, macos-latest, windows-latest] flutter-channel: [stable, beta] steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - name: Setup Flutter uses: subosito/flutter-action@v2 with: + flutter-version: ${{ env.FLUTTER_VERSION }} channel: ${{ matrix.flutter-channel }} cache: true - - name: Get dependencies + - name: flutter pub get run: flutter pub get - - name: Verify formatting - run: dart format --set-exit-if-changed . - if: matrix.os == 'ubuntu-latest' && matrix.flutter-channel == 'stable' - - - name: Analyze code - run: flutter analyze - - - name: Run tests - run: flutter test + - name: Run all tests + run: flutter test --no-pub - name: Upload test results if: failure() uses: actions/upload-artifact@v4 with: - name: test-results-${{ matrix.os }}-${{ matrix.flutter-channel }} - path: test/**/*.dart + name: test-results-${{ matrix.os }}-${{ matrix.flutter-channel }}-${{ github.run_number }} + path: | + test-results/ + **/test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index c631076..0dbe4f8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ -# Claude Code +# Claude Code session files .claude/ -*.txt # Private / internal documents (not for public repository) doc/internal/ @@ -42,10 +41,19 @@ migrate_working_dir/ /pubspec.lock **/doc/api/ .dart_tool/ +.flutter-plugins .flutter-plugins-dependencies +.metadata /build/ **/build/ /coverage/ +# Publish preparation (temporary file generated by scripts/publish.sh) +pubspec_publish_ready.yaml + +# CI pipeline artifacts (uploaded to GitHub Actions, not for VCS) +analyze_report.txt +benchmark/results/ + # Golden test failure artifacts (auto-generated by flutter test, not for VCS) test/golden/failures/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a65aa52..cca7689 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ All notable changes to HyperRender will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +## [1.1.2] - 2026-03-25 + +### ✨ New Features +- **CSS @keyframes** (`DefaultCssParser.parseKeyframes`): `@keyframes` blocks are now parsed from ` +
+

Slide-up on load

+

This card is animated purely via CSS @keyframes — no Dart code.

+
+''', + codeSnippet: + '@keyframes fadeSlideUp {\n' + ' from { opacity: 0; transform: translateY(24px); }\n' + ' to { opacity: 1; transform: translateY(0px); }\n' + '}\n' + '.card {\n' + ' animation-name: fadeSlideUp;\n' + ' animation-duration: 700ms;\n' + '}', + ), + const SizedBox(height: 12), + _buildLiveExample( + label: 'Bounce + scale combo', + accentColor: Colors.teal, + html: ''' + +
+ 🎉 Pop-in animation! +
+''', + codeSnippet: + '@keyframes popIn {\n' + ' 0% { opacity: 0; transform: scale(0.6); }\n' + ' 70% { transform: scale(1.08); }\n' + ' 100% { opacity: 1; transform: scale(1); }\n' + '}', + ), + const SizedBox(height: 12), + _buildLiveExample( + label: 'Staggered list items', + accentColor: Colors.deepOrange, + html: ''' + +
+
Item 1 — 400 ms
+
Item 2 — 600 ms
+
Item 3 — 800 ms
+
+''', + codeSnippet: + '/* Stagger via animation-duration */\n' + '.a { animation-duration: 400ms; }\n' + '.b { animation-duration: 600ms; }\n' + '.c { animation-duration: 800ms; }', + ), + ], + ); + } + + Widget _buildLiveExample({ + required String label, + required MaterialColor accentColor, + required String html, + required String codeSnippet, + }) { + return Container( + decoration: BoxDecoration( + color: accentColor.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: accentColor.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 0), + child: Row( + children: [ + Icon(Icons.play_circle_filled, + color: accentColor.shade700, size: 18), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontWeight: FontWeight.bold, + color: accentColor.shade800, + fontSize: 14, + ), + ), + ], + ), ), - const SizedBox(height: 6), - _buildStatusRow( - '✅', - 'Widget-level animations work perfectly with HyperAnimatedWidget', - Colors.green.shade700, + // Live render + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: accentColor.shade100), + ), + padding: const EdgeInsets.all(8), + child: HyperViewer( + html: html, + mode: HyperRenderMode.sync, + ), + ), ), - const SizedBox(height: 6), - _buildStatusRow( - '🗺️', - 'CSS animation support is planned for a future version', - Colors.blue.shade700, + // Code snippet + Container( + width: double.infinity, + decoration: BoxDecoration( + color: const Color(0xFF1E1E2E), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(12), + bottomRight: Radius.circular(12), + ), + ), + padding: const EdgeInsets.all(12), + child: Text( + codeSnippet, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: Color(0xFFCDD6F4), + height: 1.5, + ), + ), ), ], ), ); } - Widget _buildStatusRow(String emoji, String text, Color color) { + Widget _sectionHeader(String title, IconData icon) { return Row( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(emoji, style: const TextStyle(fontSize: 16)), + Icon(icon, color: DemoColors.accent, size: 24), const SizedBox(width: 8), - Expanded( - child: Text(text, style: TextStyle(fontSize: 13, color: color)), + Text( + title, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), ], ); @@ -104,7 +330,7 @@ class _DisclaimerCard extends StatelessWidget { } // ============================================================================= -// SECTION 1: WIDGET ANIMATIONS +// SECTION 2: WIDGET-LEVEL ANIMATIONS // ============================================================================= class _WidgetAnimationsSection extends StatelessWidget { @@ -115,58 +341,36 @@ class _WidgetAnimationsSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionHeader('Widget Animations', Icons.animation), - const SizedBox(height: 12), + _buildSectionHeader('Widget-level Animations', Icons.animation), + const SizedBox(height: 8), const Text( - 'HyperAnimatedWidget wraps any widget with keyframe animations. ' - 'These work at the Flutter widget level, independent of CSS parsing.', + 'Wrap any widget with HyperAnimatedWidget to animate it independently ' + 'of the HTML content.', style: TextStyle(fontSize: 14, color: Colors.black87), ), const SizedBox(height: 16), - _buildAnimationCard( - 'fadeIn', - 'Fade In', - Colors.blue.shade50, - Colors.blue.shade700, - ), + _buildAnimationCard('fadeIn', 'Fade In', + Colors.blue.shade50, Colors.blue.shade700), const SizedBox(height: 12), - _buildAnimationCard( - 'slideInLeft', - 'Slide In from Left', - Colors.purple.shade50, - Colors.purple.shade700, - ), + _buildAnimationCard('slideInLeft', 'Slide In from Left', + Colors.purple.shade50, Colors.purple.shade700), const SizedBox(height: 12), - _buildAnimationCard( - 'bounce', - 'Bounce', - Colors.green.shade50, - Colors.green.shade700, - ), + _buildAnimationCard('bounce', 'Bounce', + Colors.green.shade50, Colors.green.shade700), const SizedBox(height: 12), - _buildAnimationCard( - 'pulse', - 'Pulse', - Colors.orange.shade50, - Colors.orange.shade700, - ), + _buildAnimationCard('pulse', 'Pulse', + Colors.orange.shade50, Colors.orange.shade700), ], ); } Widget _buildAnimationCard( - String name, - String label, - Color bgColor, - Color textColor, - ) { + String name, String label, Color bgColor, Color textColor) { const html = '''
-

Animated Content

-

- This HyperViewer widget is wrapped with a widget-level animation. - The HTML content itself has no animation CSS — the animation is applied - by HyperAnimatedWidget at the Flutter layer. +

Animated Content

+

+ Wrapped with HyperAnimatedWidget at the Flutter layer.

'''; @@ -177,28 +381,29 @@ class _WidgetAnimationsSection extends StatelessWidget { borderRadius: BorderRadius.circular(12), border: Border.all(color: textColor.withValues(alpha: 0.3)), ), - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Icon(Icons.play_circle, color: textColor, size: 20), - const SizedBox(width: 8), + Icon(Icons.play_circle, color: textColor, size: 18), + const SizedBox(width: 6), Text(label, style: TextStyle( fontWeight: FontWeight.bold, color: textColor, - fontSize: 15)), + fontSize: 14)), const Spacer(), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( - color: textColor.withValues(alpha: 0.15), + color: textColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8), ), child: Text( - 'animation: "$name"', + 'animationName: "$name"', style: TextStyle( fontFamily: 'monospace', fontSize: 11, @@ -207,7 +412,7 @@ class _WidgetAnimationsSection extends StatelessWidget { ), ], ), - const SizedBox(height: 12), + const SizedBox(height: 10), HyperAnimatedWidget( animationName: name, duration: const Duration(milliseconds: 800), @@ -215,14 +420,11 @@ class _WidgetAnimationsSection extends StatelessWidget { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), - border: Border.all( - color: textColor.withValues(alpha: 0.2)), + border: + Border.all(color: textColor.withValues(alpha: 0.2)), ), padding: const EdgeInsets.all(8), - child: HyperViewer( - html: html, - mode: HyperRenderMode.sync, - ), + child: HyperViewer(html: html, mode: HyperRenderMode.sync), ), ), ], @@ -235,17 +437,16 @@ class _WidgetAnimationsSection extends StatelessWidget { children: [ Icon(icon, color: DemoColors.accent, size: 24), const SizedBox(width: 8), - Text( - title, - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), + Text(title, + style: + const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), ], ); } } // ============================================================================= -// SECTION 2: EXTENSION METHODS +// SECTION 3: EXTENSION METHODS // ============================================================================= class _ExtensionMethodsSection extends StatefulWidget { @@ -256,7 +457,8 @@ class _ExtensionMethodsSection extends StatefulWidget { _ExtensionMethodsSectionState(); } -class _ExtensionMethodsSectionState extends State<_ExtensionMethodsSection> { +class _ExtensionMethodsSectionState + extends State<_ExtensionMethodsSection> { bool _visible = true; @override @@ -268,19 +470,17 @@ class _ExtensionMethodsSectionState extends State<_ExtensionMethodsSection> { children: [ Icon(Icons.extension, color: DemoColors.accent, size: 24), SizedBox(width: 8), - Text( - 'Extension Methods', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), + Text('Extension Methods', + style: + TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), ], ), - const SizedBox(height: 12), + const SizedBox(height: 8), const Text( - 'HyperAnimationExtension adds convenient animation methods to any Widget.', + 'HyperAnimationExtension adds convenience methods to any Widget.', style: TextStyle(fontSize: 14, color: Colors.black87), ), const SizedBox(height: 12), - // Code sample Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( @@ -288,41 +488,41 @@ class _ExtensionMethodsSectionState extends State<_ExtensionMethodsSection> { borderRadius: BorderRadius.circular(8), ), child: const Text( - '// Extension methods on Widget:\n' 'HyperViewer(html: html)\n' - ' .fadeIn() // Fade in on first render\n' - ' .slideInLeft() // Slide from left\n' - ' .bounce() // Bounce effect\n' - ' .pulse() // Pulse scale effect', + ' .fadeIn() // Fade in on first build\n' + ' .slideInLeft() // Slide from left\n' + ' .bounce() // Bounce effect\n' + ' .pulse() // Pulse scale effect\n' + ' .spin() // Continuous rotation', style: TextStyle( fontFamily: 'monospace', fontSize: 13, color: Color(0xFFCDD6F4), + height: 1.6, ), ), ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ElevatedButton.icon( - onPressed: () => setState(() => _visible = !_visible), - icon: Icon(_visible ? Icons.visibility_off : Icons.visibility), - label: Text(_visible ? 'Hide (will fadeIn on show)' : 'Show with fadeIn'), - style: ElevatedButton.styleFrom( - backgroundColor: DemoColors.accent, - foregroundColor: Colors.white, - ), + const SizedBox(height: 14), + Center( + child: ElevatedButton.icon( + onPressed: () => setState(() => _visible = !_visible), + icon: Icon(_visible ? Icons.visibility_off : Icons.visibility), + label: Text(_visible + ? 'Hide (re-show triggers fadeIn)' + : 'Show with fadeIn'), + style: ElevatedButton.styleFrom( + backgroundColor: DemoColors.accent, + foregroundColor: Colors.white, ), - ], + ), ), - const SizedBox(height: 12), + const SizedBox(height: 10), if (_visible) HyperViewer( - html: '
' - '

fadeIn() extension applied to this HyperViewer.

' - '

' - 'Hide and show to see the animation trigger again.

', + html: '
' + '

fadeIn() applied via extension method.

' + '

' + 'Hide and show to replay.

', mode: HyperRenderMode.sync, ).fadeIn(), ], @@ -331,85 +531,53 @@ class _ExtensionMethodsSectionState extends State<_ExtensionMethodsSection> { } // ============================================================================= -// SECTION 3: ROADMAP +// SECTION 4: CAPABILITY TABLE // ============================================================================= -class _RoadmapSection extends StatelessWidget { - const _RoadmapSection(); +class _CapabilityTable extends StatelessWidget { + const _CapabilityTable(); @override Widget build(BuildContext context) { + final rows = [ + _Cap('animation-name', 'CSS @keyframes by name', true), + _Cap('animation-duration', 'Timing control', true), + _Cap('animation-timing-function', 'ease, linear, ease-in/out', true), + _Cap('animation-delay', 'Delay before start', true), + _Cap('animation-iteration-count', 'Repeat count (1, 2, …)', true), + _Cap('animation-direction', 'normal, reverse, alternate', true), + _Cap('opacity keyframe', 'Fade in/out', true), + _Cap('transform: translate', 'Slide animations', true), + _Cap('transform: scale', 'Zoom animations', true), + _Cap('transform: rotate', 'Spin animations', true), + _Cap('@-webkit-keyframes', 'Vendor prefix support', true), + _Cap('animation-iteration-count: infinite', + 'Infinite loop (wiring in progress)', false), + _Cap('CSS transitions', 'Property transitions', false), + _Cap('transform: skew', 'Skew transforms', false), + ]; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Row( children: [ - Icon(Icons.map, color: DemoColors.accent, size: 24), + Icon(Icons.table_chart, color: DemoColors.accent, size: 24), SizedBox(width: 8), - Text( - 'CSS Animation Roadmap', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), + Text('CSS Animation Coverage', + style: + TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), ], ), const SizedBox(height: 12), Card( - elevation: 2, + elevation: 1, child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(4), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Planned CSS Animation Features', - style: TextStyle( - fontWeight: FontWeight.bold, fontSize: 16), - ), - const SizedBox(height: 12), - _buildRoadmapItem( - '📌', 'animation-name', 'Link CSS keyframes by name', - false), - _buildRoadmapItem( - '📌', 'animation-duration', 'Control animation timing', - false), - _buildRoadmapItem( - '📌', 'animation-timing-function', - 'ease, linear, cubic-bezier()', false), - _buildRoadmapItem( - '📌', 'animation-iteration-count', - 'infinite, 1, 2, etc.', false), - _buildRoadmapItem( - '📌', 'animation-direction', 'normal, reverse, alternate', - false), - _buildRoadmapItem( - '📌', 'transition', 'CSS property transitions', false), - _buildRoadmapItem( - '📌', 'transform', 'translate, rotate, scale, skew', false), - const Divider(height: 24), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.green.shade50, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.green.shade200), - ), - child: Row( - children: [ - const Icon(Icons.check_circle, - color: Colors.green, size: 20), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Current: HyperKeyframes system provides ' - 'equivalent functionality at the widget level', - style: TextStyle( - fontSize: 13, color: Colors.green.shade800), - ), - ), - ], - ), - ), - ], + children: rows + .map((r) => _buildRow(r.property, r.description, r.done)) + .toList(), ), ), ), @@ -417,14 +585,13 @@ class _RoadmapSection extends StatelessWidget { ); } - Widget _buildRoadmapItem( - String emoji, String property, String description, bool done) { + Widget _buildRow(String property, String desc, bool done) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 5), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(done ? '✅' : emoji, style: const TextStyle(fontSize: 16)), + Text(done ? '✅' : '🚧', + style: const TextStyle(fontSize: 15)), const SizedBox(width: 10), Expanded( child: Column( @@ -432,12 +599,13 @@ class _RoadmapSection extends StatelessWidget { children: [ Text(property, style: TextStyle( - fontWeight: FontWeight.w600, fontFamily: 'monospace', + fontSize: 13, + fontWeight: FontWeight.w600, color: done - ? Colors.green.shade700 - : Colors.black87)), - Text(description, + ? Colors.green.shade800 + : Colors.grey.shade700)), + Text(desc, style: TextStyle( fontSize: 12, color: Colors.grey.shade600)), ], @@ -448,3 +616,10 @@ class _RoadmapSection extends StatelessWidget { ); } } + +class _Cap { + final String property; + final String description; + final bool done; + const _Cap(this.property, this.description, this.done); +} diff --git a/example/lib/css_animations_demo.dart b/example/lib/css_animations_demo.dart new file mode 100644 index 0000000..d94f809 --- /dev/null +++ b/example/lib/css_animations_demo.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:hyper_render/hyper_render.dart'; + +/// Demo showcase for CSS Keyframes and Animations in HyperRender. +/// +/// Demonstrates: +/// - CSS @keyframes definition +/// - Animation shorthand property +/// - Multi-state animations (fade, slide, rotate) +class CssAnimationsDemo extends StatelessWidget { + const CssAnimationsDemo({super.key}); + + final String _htmlContent = ''' + +

CSS Animations Demo

+

HyperRender supports standard CSS keyframes with animation property.

+ +

Fade Animation

+
+ +

Slide Animation

+
+ +

Rotate Animation

+
+ '''; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('CSS Animations Demo')), + body: HyperViewer( + html: _htmlContent, + enableComplexFilters: true, + ), + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 3d73bf1..3662c83 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -29,6 +29,7 @@ import 'cjk_languages_demo.dart'; import 'email_demo.dart'; import 'stress_test_demo.dart'; import 'why_hyper_render_demo.dart'; +import 'css_animations_demo.dart'; import 'enterprise_features_demo.dart'; /// Optimized base TextStyle for better readability diff --git a/example/lib/performance_deep_dive_demo.dart b/example/lib/performance_deep_dive_demo.dart index 1ea5480..ca6f8e7 100644 --- a/example/lib/performance_deep_dive_demo.dart +++ b/example/lib/performance_deep_dive_demo.dart @@ -991,7 +991,7 @@ class _MemoryTabState extends State<_MemoryTab> { const SizedBox(height: 16), // Doc size selector DropdownButtonFormField( - initialValue: _selectedDocSize, + value: _selectedDocSize, decoration: const InputDecoration( labelText: 'Document Size', border: OutlineInputBorder(), diff --git a/example/lib/sprint3_demo.dart b/example/lib/sprint3_demo.dart index 4d0b9be..df9296e 100644 --- a/example/lib/sprint3_demo.dart +++ b/example/lib/sprint3_demo.dart @@ -425,20 +425,20 @@ class _Sprint3DemoState extends State Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.amber.shade50, - border: Border.all(color: Colors.amber.shade300), + color: Colors.green.shade50, + border: Border.all(color: Colors.green.shade300), borderRadius: BorderRadius.circular(8), ), child: const Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.info_outline, color: Colors.amber, size: 18), + Icon(Icons.check_circle_outline, color: Colors.green, size: 18), SizedBox(width: 8), Expanded( child: Text( - 'For full SVG support (gradients, filters, animations), ' - 'add flutter_svg to your project and register a custom widget ' - 'builder via HyperViewer\'s widgetBuilder parameter.', + 'SVG is rendered natively via flutter_svg (built-in). ' + 'Inline , , and data:image/svg+xml URIs ' + 'are all handled automatically — no extra setup needed.', style: TextStyle(fontSize: 12, color: Colors.black87, height: 1.4), ), ), diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index 545dd3a..17ac5cc 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,7 +8,6 @@ import Foundation import audio_session import just_audio import package_info_plus -import path_provider_foundation import share_plus import sqflite_darwin import url_launcher_macos @@ -20,7 +19,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index 4c267fc..40a2158 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -7,9 +7,6 @@ PODS: - FlutterMacOS - package_info_plus (0.0.1): - FlutterMacOS - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - share_plus (0.0.1): - FlutterMacOS - sqflite_darwin (0.0.4): @@ -31,7 +28,6 @@ DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) - just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/darwin`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) @@ -48,8 +44,6 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/just_audio/darwin package_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos - path_provider_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin share_plus: :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos sqflite_darwin: @@ -68,13 +62,12 @@ SPEC CHECKSUMS: FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed package_info_plus: f0052d280d17aa382b932f399edf32507174e870 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 - video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b + url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd + video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b - webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 + webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 diff --git a/example/pubspec.lock b/example/pubspec.lock index db9ab60..32af2b1 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: audio_session - sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac" + sha256: "7217b229db57cc4dc577a8abb56b7429a5a212b978517a5be578704bfe5e568b" url: "https://pub.dev" source: hosted - version: "0.1.25" + version: "0.2.3" boolean_selector: dependency: transitive description: @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -125,10 +133,10 @@ packages: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" fake_async: dependency: transitive description: @@ -141,10 +149,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" file: dependency: transitive description: @@ -202,10 +210,10 @@ packages: dependency: transitive description: name: flutter_svg - sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.2.4" flutter_test: dependency: "direct dev" description: flutter @@ -280,6 +288,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.6" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" highlight: dependency: transitive description: @@ -288,6 +304,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" html: dependency: transitive description: @@ -318,35 +342,35 @@ packages: path: ".." relative: true source: path - version: "1.1.0" + version: "1.1.2" hyper_render_core: dependency: "direct main" description: path: "../packages/hyper_render_core" relative: true source: path - version: "1.1.0" + version: "1.1.2" hyper_render_highlight: dependency: transitive description: path: "../packages/hyper_render_highlight" relative: true source: path - version: "1.1.0" + version: "1.1.2" hyper_render_html: dependency: transitive description: path: "../packages/hyper_render_html" relative: true source: path - version: "1.1.0" + version: "1.1.2" hyper_render_markdown: dependency: transitive description: path: "../packages/hyper_render_markdown" relative: true source: path - version: "1.1.0" + version: "1.1.2" just_audio: dependency: transitive description: @@ -399,10 +423,10 @@ packages: dependency: transitive description: name: lints - sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.1.0" list_counter: dependency: transitive description: @@ -459,6 +483,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" nested: dependency: transitive description: @@ -467,6 +499,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" octo_image: dependency: transitive description: @@ -519,18 +559,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37" + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e url: "https://pub.dev" source: hosted - version: "2.2.19" + version: "2.2.22" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -559,10 +599,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" platform: dependency: transitive description: @@ -587,6 +627,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" rxdart: dependency: transitive description: @@ -620,10 +668,10 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" sqflite: dependency: transitive description: @@ -636,10 +684,10 @@ packages: dependency: transitive description: name: sqflite_android - sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2+3" sqflite_common: dependency: transitive description: @@ -732,18 +780,18 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e" + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" url: "https://pub.dev" source: hosted - version: "6.3.20" + version: "6.3.28" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" url: "https://pub.dev" source: hosted - version: "6.3.4" + version: "6.4.1" url_launcher_linux: dependency: transitive description: @@ -756,10 +804,10 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" url: "https://pub.dev" source: hosted - version: "3.2.3" + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -772,10 +820,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" url_launcher_windows: dependency: transitive description: @@ -788,18 +836,18 @@ packages: dependency: transitive description: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.5.3" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + sha256: "7076216a10d5c390315fbe536a30f1254c341e7543e6c4c8a815e591307772b1" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.1.20" vector_graphics_codec: dependency: transitive description: @@ -812,10 +860,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.2.0" vector_math: dependency: transitive description: @@ -828,26 +876,26 @@ packages: dependency: transitive description: name: video_player - sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf" + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" url: "https://pub.dev" source: hosted - version: "2.10.1" + version: "2.11.1" video_player_android: dependency: transitive description: name: video_player_android - sha256: a8dc4324f67705de057678372bedb66cd08572fe7c495605ac68c5f503324a39 + sha256: "9862c67c4661c98f30fe707bc1a4f97d6a0faa76784f485d282668e4651a7ac3" url: "https://pub.dev" source: hosted - version: "2.8.15" + version: "2.9.4" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: f9a780aac57802b2892f93787e5ea53b5f43cc57dc107bee9436458365be71cd + sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e url: "https://pub.dev" source: hosted - version: "2.8.4" + version: "2.9.4" video_player_platform_interface: dependency: transitive description: @@ -868,26 +916,26 @@ packages: dependency: transitive description: name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" url: "https://pub.dev" source: hosted - version: "15.0.0" + version: "15.0.2" wakelock_plus: dependency: transitive description: name: wakelock_plus - sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228" + sha256: "8b12256f616346910c519a35606fb69b1fe0737c06b6a447c6df43888b097f39" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.1" wakelock_plus_platform_interface: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" + sha256: "24b84143787220a403491c2e5de0877fbbb87baf3f0b18a2a988973863db4b03" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" web: dependency: transitive description: @@ -900,18 +948,18 @@ packages: dependency: transitive description: name: webview_flutter - sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba + sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 url: "https://pub.dev" source: hosted - version: "4.13.0" + version: "4.13.1" webview_flutter_android: dependency: transitive description: name: webview_flutter_android - sha256: "9a25f6b4313978ba1c2cda03a242eea17848174912cfb4d2d8ee84a556f248e3" + sha256: "2a03df01df2fd30b075d1e7f24c28aee593f2e5d5ac4c3c4283c5eda63717b24" url: "https://pub.dev" source: hosted - version: "4.10.1" + version: "4.10.13" webview_flutter_platform_interface: dependency: transitive description: @@ -924,10 +972,10 @@ packages: dependency: transitive description: name: webview_flutter_wkwebview - sha256: fb46db8216131a3e55bcf44040ca808423539bc6732e7ed34fb6d8044e3d512f + sha256: "0d85e8bc5db9a7c49f6ff57cbeafc6cd8216ad9c9ebc70b2c4579d955698933a" url: "https://pub.dev" source: hosted - version: "3.23.0" + version: "3.24.1" win32: dependency: transitive description: @@ -952,6 +1000,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.32.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/lib/hyper_render.dart b/lib/hyper_render.dart index 1b9cdf1..caf0d16 100644 --- a/lib/hyper_render.dart +++ b/lib/hyper_render.dart @@ -151,6 +151,8 @@ export 'src/utils/html_sanitizer.dart' show HtmlSanitizer; export 'src/utils/html_heuristics.dart' show HtmlHeuristics; +export 'src/utils/svg_builder.dart' show buildSvgWidget; + // ============================================ // Parsers & adapters // ============================================ diff --git a/lib/src/core/animation_controller.dart b/lib/src/core/animation_controller.dart index 32e0f2d..d55819f 100644 --- a/lib/src/core/animation_controller.dart +++ b/lib/src/core/animation_controller.dart @@ -162,6 +162,22 @@ class HyperAnimations { return null; } } + + /// All predefined animations as a name → keyframes map. + static Map get all => const { + 'fadeIn': fadeIn, + 'fadeOut': fadeOut, + 'slideInLeft': slideInLeft, + 'slideInRight': slideInRight, + 'slideInUp': slideInUp, + 'slideInDown': slideInDown, + 'bounce': bounce, + 'pulse': pulse, + 'shake': shake, + 'spin': spin, + 'zoomIn': zoomIn, + 'zoomOut': zoomOut, + }; } /// A single keyframe in an animation @@ -266,6 +282,11 @@ class HyperAnimatedWidget extends StatefulWidget { final bool reverse; final bool autoPlay; + /// Optional registry of custom [HyperKeyframes] keyed by animation name. + /// + /// Checked first before the built-in [HyperAnimations] presets. + final Map? keyframesLookup; + const HyperAnimatedWidget({ super.key, required this.child, @@ -276,6 +297,7 @@ class HyperAnimatedWidget extends StatefulWidget { this.iterationCount, this.reverse = false, this.autoPlay = true, + this.keyframesLookup, }); /// Create from ComputedStyle @@ -283,6 +305,7 @@ class HyperAnimatedWidget extends StatefulWidget { Key? key, required Widget child, required ComputedStyle style, + Map? keyframesLookup, }) { return HyperAnimatedWidget( key: key, @@ -293,6 +316,7 @@ class HyperAnimatedWidget extends StatefulWidget { iterationCount: style.animationIterationCount, reverse: style.animationDirection == HyperAnimationDirection.reverse || style.animationDirection == HyperAnimationDirection.alternateReverse, + keyframesLookup: keyframesLookup, child: child, ); } @@ -341,8 +365,10 @@ class _HyperAnimatedWidgetState extends State ); // Get keyframes + // Resolve keyframes: custom registry takes priority over built-ins. if (widget.animationName != null) { - _keyframes = HyperAnimations.byName(widget.animationName!); + _keyframes = widget.keyframesLookup?[widget.animationName!] ?? + HyperAnimations.byName(widget.animationName!); } // Setup iteration listener @@ -385,7 +411,8 @@ class _HyperAnimatedWidgetState extends State if (oldWidget.animationName != widget.animationName || oldWidget.duration != widget.duration || - oldWidget.curve != widget.curve) { + oldWidget.curve != widget.curve || + oldWidget.keyframesLookup != widget.keyframesLookup) { _controller.dispose(); _setupAnimation(); } diff --git a/lib/src/core/image_provider.dart b/lib/src/core/image_provider.dart index e580527..c5d56f9 100644 --- a/lib/src/core/image_provider.dart +++ b/lib/src/core/image_provider.dart @@ -3,6 +3,7 @@ import 'dart:ui' as ui; import 'package:flutter/widgets.dart'; /// Image load state + /// State of image loading. enum ImageLoadState { loading, loaded, @@ -10,6 +11,7 @@ enum ImageLoadState { } /// Cached image data + /// Cache entry for images. class CachedImage { final ui.Image? image; final ImageLoadState state; @@ -31,6 +33,7 @@ class CachedImage { /// - src: The image URL /// - onLoad: Callback when image loads successfully /// - onError: Callback when image fails to load + /// Loader for fetching images. typedef HyperImageLoader = void Function( String src, void Function(ui.Image image) onLoad, diff --git a/lib/src/core/kinsoku_processor.dart b/lib/src/core/kinsoku_processor.dart index 187b6a2..ef18ca6 100644 --- a/lib/src/core/kinsoku_processor.dart +++ b/lib/src/core/kinsoku_processor.dart @@ -5,10 +5,10 @@ /// - Kinsoku End (行末禁則): Characters that cannot end a line /// /// Reference: JIS X 4051 (日本語文書の組版方法) -/// Reference: doc3.md - "Requirement 3: CJK/Japanese Line-breaking" library; /// Kinsoku processor for CJK line-breaking rules + /// Processor that applies CJK line-breaking rules. class KinsokuProcessor { /// Characters that cannot start a line (行頭禁則文字) /// diff --git a/lib/src/core/lazy_image_queue.dart b/lib/src/core/lazy_image_queue.dart index ecf34cd..b6c3d58 100644 --- a/lib/src/core/lazy_image_queue.dart +++ b/lib/src/core/lazy_image_queue.dart @@ -7,15 +7,24 @@ import 'dart:ui' as ui; /// Images are loaded in ascending priority order (lower number = closer to /// viewport = loaded first). At most [maxConcurrent] loads run in parallel. /// +/// Each [enqueue] call returns a subscription token. Pass that token to +/// [cancel] when the requesting widget is disposed so its callback is removed +/// before the image arrives. This prevents: +/// - Callbacks firing on detached RenderObjects. +/// - GPU memory leaks from abandoned [ui.Image] instances. +/// - One viewer's disposal cancelling another viewer's pending load. +/// /// Usage: /// ```dart -/// LazyImageQueue.instance.enqueue( +/// final token = LazyImageQueue.instance.enqueue( /// url: 'https://example.com/image.png', /// priority: 0, // 0 = in viewport, higher = further away /// loader: myLoader, /// onLoad: (img) { ... }, /// onError: (e) { ... }, /// ); +/// // On dispose: +/// LazyImageQueue.instance.cancel(token); /// ``` class LazyImageQueue { LazyImageQueue._(); @@ -26,36 +35,61 @@ class LazyImageQueue { int maxConcurrent = 3; final _queue = SplayTreeMap<_QueueKey, _PendingLoad>(); + + /// Loads currently in-flight (loader called, result not yet received). + final Set<_PendingLoad> _inFlight = {}; + int _active = 0; int _sequenceCounter = 0; + int _subscriptionCounter = 0; + + /// Maps subscription token → url for O(1) cancel lookup. + final Map _tokenToUrl = {}; /// Enqueue an image load request. /// + /// Returns a subscription [token] that must be passed to [cancel] when the + /// requesting widget is disposed. + /// /// [priority] — lower means higher urgency (0 = viewport-visible). - /// If the same [url] is already loading or queued, the new request is - /// merged: callbacks are appended and priority is updated if lower. - void enqueue({ + /// + /// If the same [url] is already queued or in-flight, the new callback is + /// merged into the existing load; priority is lowered if the new value is + /// smaller. No duplicate network request is made. + int enqueue({ required String url, required int priority, required HyperImageLoader loader, required void Function(ui.Image) onLoad, required void Function(Object) onError, }) { - // Already loading → just add callback. + final token = _subscriptionCounter++; + _tokenToUrl[token] = url; + + // Check in-flight first — deduplicates requests for URLs already loading. + for (final load in _inFlight) { + if (load.url == url) { + load.onLoadCallbacks[token] = onLoad; + load.onErrorCallbacks[token] = onError; + return token; + } + } + + // Check queued — merge or re-prioritise. final existing = _findQueued(url); if (existing != null) { if (priority < existing.priority) { // Re-prioritise: remove and re-insert with new priority. _queue.remove(existing.key); final updated = existing.copyWithPriority(priority, _sequenceCounter++); - updated.onLoadCallbacks.add(onLoad); - updated.onErrorCallbacks.add(onError); + updated.onLoadCallbacks[token] = onLoad; + updated.onErrorCallbacks[token] = onError; _queue[updated.key] = updated; } else { - existing.onLoadCallbacks.add(onLoad); - existing.onErrorCallbacks.add(onError); + existing.onLoadCallbacks[token] = onLoad; + existing.onErrorCallbacks[token] = onError; } - return; + return token; } final key = _QueueKey(priority, _sequenceCounter++); @@ -64,16 +98,66 @@ class LazyImageQueue { url: url, priority: priority, loader: loader, - onLoadCallbacks: [onLoad], - onErrorCallbacks: [onError], + onLoadCallbacks: {token: onLoad}, + onErrorCallbacks: {token: onError}, ); _pump(); + return token; + } + + /// Remove this widget's subscription. Safe to call after completion (no-op). + /// + /// - If the load is still queued and this was the last subscriber, the queue + /// entry is removed entirely (avoids a wasted network request). + /// - If the load is in-flight, the callback is removed; when the image + /// arrives and no subscribers remain, the [ui.Image] is disposed so GPU + /// memory is not leaked. + void cancel(int token) { + final url = _tokenToUrl.remove(token); + if (url == null) return; // already completed or invalid token + + // Search queued loads. + for (final entry in _queue.entries) { + if (entry.value.url == url) { + final load = entry.value; + load.onLoadCallbacks.remove(token); + load.onErrorCallbacks.remove(token); + // Remove the queue entry if no subscribers remain — no point loading. + if (load.onLoadCallbacks.isEmpty) { + _queue.remove(entry.key); + } + return; + } + } + + // Search in-flight loads. + for (final load in _inFlight) { + if (load.url == url) { + load.onLoadCallbacks.remove(token); + load.onErrorCallbacks.remove(token); + // Do not remove from _inFlight; the loader will still complete and + // _startLoad will dispose the image if no callbacks remain. + return; + } + } } - /// Remove all pending requests (e.g., when the widget is disposed). - void cancelAll(String url) { - _queue.removeWhere((_, load) => load.url == url); + /// Resets all internal state for testing purposes. + /// + /// Clears the pending queue and resets the in-flight counter so that each + /// test starts with a clean singleton. Only available in debug/test builds. + void resetForTesting() { + for (final load in _inFlight) { + load.onLoadCallbacks.clear(); + load.onErrorCallbacks.clear(); + } + _inFlight.clear(); + _queue.clear(); + _tokenToUrl.clear(); + _active = 0; + _sequenceCounter = 0; + _subscriptionCounter = 0; } void _pump() { @@ -86,21 +170,45 @@ class LazyImageQueue { void _startLoad(_PendingLoad load) { _active++; + _inFlight.add(load); load.loader( load.url, (ui.Image image) { _active--; - for (final cb in load.onLoadCallbacks) { - cb(image); + _inFlight.remove(load); + + // Remove token mappings for all remaining subscribers (load complete). + for (final token in load.onLoadCallbacks.keys) { + _tokenToUrl.remove(token); } + + if (load.onLoadCallbacks.isEmpty) { + // All subscribers cancelled before image arrived. + // Dispose to prevent GPU memory leak — Dart GC cannot free ui.Image. + image.dispose(); + } else { + // Distribute independent clones so each subscriber owns its resource. + // Disposing a clone does not affect other clones or the original. + for (final cb in load.onLoadCallbacks.values) { + cb(image.clone()); + } + image.dispose(); // release the source after cloning + } + _pump(); }, (Object error) { _active--; - for (final cb in load.onErrorCallbacks) { + _inFlight.remove(load); + + for (final token in load.onErrorCallbacks.keys) { + _tokenToUrl.remove(token); + } + for (final cb in load.onErrorCallbacks.values) { cb(error); } + _pump(); }, ); @@ -145,8 +253,10 @@ class _PendingLoad { final String url; int priority; final HyperImageLoader loader; - final List onLoadCallbacks; - final List onErrorCallbacks; + + /// Keyed by subscription token so individual subscribers can be cancelled. + final Map onLoadCallbacks; + final Map onErrorCallbacks; _PendingLoad({ required this.key, @@ -163,7 +273,7 @@ class _PendingLoad { url: url, priority: newPriority, loader: loader, - onLoadCallbacks: List.of(onLoadCallbacks), - onErrorCallbacks: List.of(onErrorCallbacks), + onLoadCallbacks: Map.of(onLoadCallbacks), + onErrorCallbacks: Map.of(onErrorCallbacks), ); } diff --git a/lib/src/core/render_hyper_box.dart b/lib/src/core/render_hyper_box.dart index f713a0f..456b4cd 100644 --- a/lib/src/core/render_hyper_box.dart +++ b/lib/src/core/render_hyper_box.dart @@ -23,12 +23,15 @@ part 'render_hyper_box_selection.dart'; part 'render_hyper_box_accessibility.dart'; /// Callback for handling link taps +/// Callback when a link is tapped. typedef HyperLinkTapCallback = void Function(String url); /// Callback for building custom widgets for embedded content +/// Builder for custom widgets. typedef HyperWidgetBuilder = Widget? Function(UDTNode node); /// Callback when image loading state changes +/// Callback for image loading events. typedef ImageLoadCallback = void Function(String src, ImageLoadState state); /// RenderHyperBox - The core custom rendering engine @@ -42,8 +45,7 @@ typedef ImageLoadCallback = void Function(String src, ImageLoadState state); /// - Inline background/border for wrapped text /// - Async image loading /// -/// Reference: doc1.txt - "Quy trình 4 bước của thuật toán" -/// Reference: doc3.md - "RenderObject-centric Architecture" +/// Main RenderObject for HyperRender. class RenderHyperBox extends RenderBox with ContainerRenderObjectMixin, @@ -104,6 +106,10 @@ class RenderHyperBox extends RenderBox /// Image cache final Map _imageCache = {}; + /// Subscription tokens returned by [LazyImageQueue.enqueue]. + /// Cancelled in [_disposeImages] so in-flight callbacks are dropped safely. + final Set _imageTokens = {}; + /// Current text selection HyperTextSelection? _selection; @@ -289,6 +295,14 @@ class RenderHyperBox extends RenderBox } void _disposeImages() { + // Cancel all pending/in-flight subscriptions so callbacks are not invoked + // on this (now-disposing) RenderBox. For in-flight loads the queue will + // dispose the ui.Image itself if no other subscribers remain. + for (final token in _imageTokens) { + LazyImageQueue.instance.cancel(token); + } + _imageTokens.clear(); + // BUG-C FIX: ui.Image is a native GPU resource that must be explicitly // disposed. Calling _imageCache.clear() without dispose() leaks GPU // texture memory — the Dart GC cannot free it. This matters especially @@ -391,12 +405,21 @@ class RenderHyperBox extends RenderBox final loader = _imageLoader ?? defaultImageLoader; - LazyImageQueue.instance.enqueue( + // late allows the closures below to reference `token` before it is + // assigned; they only execute asynchronously, after enqueue() returns. + late final int token; + token = LazyImageQueue.instance.enqueue( url: src, priority: priority, loader: loader, onLoad: (ui.Image image) { - if (!attached) return; + if (!attached) { + // Safety net: token should have been cancelled by _disposeImages(), + // but guard here in case of unusual teardown ordering. + image.dispose(); + return; + } + _imageTokens.remove(token); _imageCache[src] = CachedImage( image: image, state: ImageLoadState.loaded, @@ -409,6 +432,7 @@ class RenderHyperBox extends RenderBox }, onError: (Object error) { if (!attached) return; + _imageTokens.remove(token); _imageCache[src] = CachedImage( state: ImageLoadState.error, error: error.toString(), @@ -416,6 +440,7 @@ class RenderHyperBox extends RenderBox markNeedsPaint(); }, ); + _imageTokens.add(token); } // ============================================ diff --git a/lib/src/core/render_hyper_box_layout.dart b/lib/src/core/render_hyper_box_layout.dart index 17f9208..8a343f0 100644 --- a/lib/src/core/render_hyper_box_layout.dart +++ b/lib/src/core/render_hyper_box_layout.dart @@ -956,6 +956,17 @@ extension _RenderHyperBoxLayout on RenderHyperBox { return null; } + // Snap breakIndex off any UTF-16 low surrogate (0xDC00–0xDFFF). + // Low surrogates are the *second* code unit of a surrogate pair (emoji, + // rare CJK extension B, etc.). If breakIndex lands there, substring() + // would put the lone high surrogate at the end of the first fragment — + // an invalid Dart string that crashes TextPainter and corrupts clipboard. + // Stepping back by one puts the break BEFORE the whole surrogate pair. + if ((text.codeUnitAt(breakIndex) & 0xFC00) == 0xDC00) { + breakIndex -= 1; + if (breakIndex <= 0) return null; + } + // Only trim spaces for normal/nowrap/pre-line modes // For pre/pre-wrap/break-spaces, preserve all whitespace final whiteSpace = fragment.style.whiteSpace; @@ -968,9 +979,6 @@ extension _RenderHyperBoxLayout on RenderHyperBox { : text.substring(0, breakIndex); final secondRaw = text.substring(breakIndex); final secondPart = shouldTrim ? secondRaw.trimLeft() : secondRaw; - - // account for trimmed chars in offset - final trimmedLeading = shouldTrim ? secondRaw.length - secondPart.length : 0; if (firstPart.isEmpty || secondPart.isEmpty) { return null; @@ -984,11 +992,15 @@ extension _RenderHyperBoxLayout on RenderHyperBox { ); _measureFragment(firstFragment); + // characterOffset points to the START of secondPart in the document. + // Use `breakIndex` (not `breakIndex + trimmedChars`) so that trimmed + // leading spaces are still covered by this fragment's character range — + // adding trimmedChars would create a gap in the selection mapping. final secondFragment = Fragment.text( text: secondPart, sourceNode: fragment.sourceNode, style: fragment.style, - characterOffset: fragment.characterOffset + breakIndex + trimmedLeading, + characterOffset: fragment.characterOffset + breakIndex, ); _measureFragment(secondFragment); @@ -1054,6 +1066,12 @@ extension _RenderHyperBoxLayout on RenderHyperBox { return null; } + // Snap off any low surrogate — same reason as in _splitTextFragment. + if ((text.codeUnitAt(breakIndex) & 0xFC00) == 0xDC00) { + breakIndex -= 1; + if (breakIndex <= 0) return null; + } + final firstPart = text.substring(0, breakIndex); final secondPart = text.substring(breakIndex); @@ -1369,7 +1387,9 @@ extension _RenderHyperBoxLayout on RenderHyperBox { // Build ranges instead of individual character mapping for (final fragment in _fragments) { - if (fragment.type == FragmentType.text && fragment.text != null) { + if ((fragment.type == FragmentType.text || + fragment.type == FragmentType.ruby) && + fragment.text != null) { final startIdx = _totalCharacterCount; final endIdx = startIdx + fragment.text!.length; _fragmentRanges.add((startIdx, endIdx, fragment)); diff --git a/lib/src/core/render_hyper_box_paint.dart b/lib/src/core/render_hyper_box_paint.dart index 3bee44f..d8a86fb 100644 --- a/lib/src/core/render_hyper_box_paint.dart +++ b/lib/src/core/render_hyper_box_paint.dart @@ -241,6 +241,26 @@ extension _RenderHyperBoxPaint on RenderHyperBox { canvas.drawRect(selectionRect, selectionPaint); } + currentOffset = fragmentEnd; + } else if (fragment.type == FragmentType.ruby && + fragment.text != null) { + // Ruby fragments contribute to character offset and get a full-rect + // highlight covering both the annotation and the base text. + final fragmentStart = currentOffset; + final fragmentEnd = currentOffset + fragment.text!.length; + + if (fragmentEnd > _selection!.start && + fragmentStart < _selection!.end) { + final fragmentOffset = fragment.offset ?? Offset.zero; + final selectionRect = Rect.fromLTWH( + offset.dx + fragmentOffset.dx, + offset.dy + fragmentOffset.dy, + fragment.width, + fragment.height, + ); + canvas.drawRect(selectionRect, selectionPaint); + } + currentOffset = fragmentEnd; } } diff --git a/lib/src/core/render_hyper_box_selection.dart b/lib/src/core/render_hyper_box_selection.dart index 82bebed..e89ae72 100644 --- a/lib/src/core/render_hyper_box_selection.dart +++ b/lib/src/core/render_hyper_box_selection.dart @@ -40,6 +40,23 @@ extension RenderHyperBoxSelection on RenderHyperBox { return currentOffset + textPosition.offset; } + currentOffset += fragment.text!.length; + } else if (fragment.type == FragmentType.ruby && + fragment.text != null) { + final fragmentOffset = fragment.offset ?? Offset.zero; + final fragmentRect = Rect.fromLTWH( + fragmentOffset.dx, + fragmentOffset.dy, + fragment.width, + fragment.height, + ); + + if (position.dx >= fragmentRect.left && + position.dx <= fragmentRect.right) { + // Place cursor at start of the ruby base text. + return currentOffset; + } + currentOffset += fragment.text!.length; } } @@ -49,7 +66,9 @@ extension RenderHyperBoxSelection on RenderHyperBox { // Add character count for this line for (final fragment in line.fragments) { - if (fragment.type == FragmentType.text && fragment.text != null) { + if ((fragment.type == FragmentType.text || + fragment.type == FragmentType.ruby) && + fragment.text != null) { currentOffset += fragment.text!.length; } } @@ -68,19 +87,19 @@ extension RenderHyperBoxSelection on RenderHyperBox { int currentOffset = 0; for (final fragment in _fragments) { - if (fragment.type == FragmentType.text && fragment.text != null) { - final fragmentStart = currentOffset; - final fragmentEnd = currentOffset + fragment.text!.length; - - if (fragmentEnd > _selection!.start && - fragmentStart < _selection!.end) { - final selectStart = math.max(0, _selection!.start - fragmentStart); - final selectEnd = - math.min(fragment.text!.length, _selection!.end - fragmentStart); - buffer.write(fragment.text!.substring(selectStart, selectEnd)); - } - - currentOffset = fragmentEnd; + final isText = fragment.type == FragmentType.text && fragment.text != null; + final isRuby = fragment.type == FragmentType.ruby && fragment.text != null; + if (!isText && !isRuby) continue; + + final fragmentStart = currentOffset; + final fragmentEnd = currentOffset + fragment.text!.length; + currentOffset = fragmentEnd; + + if (fragmentEnd > _selection!.start && fragmentStart < _selection!.end) { + final selectStart = math.max(0, _selection!.start - fragmentStart); + final selectEnd = + math.min(fragment.text!.length, _selection!.end - fragmentStart); + buffer.write(fragment.text!.substring(selectStart, selectEnd)); } } @@ -151,6 +170,23 @@ extension RenderHyperBoxSelection on RenderHyperBox { )); } + currentOffset = fragmentEnd; + } else if (fragment.type == FragmentType.ruby && + fragment.text != null) { + final fragmentStart = currentOffset; + final fragmentEnd = currentOffset + fragment.text!.length; + + if (fragmentEnd > _selection!.start && + fragmentStart < _selection!.end) { + final fragmentOffset = fragment.offset ?? Offset.zero; + rects.add(Rect.fromLTWH( + fragmentOffset.dx, + fragmentOffset.dy, + fragment.width, + fragment.height, + )); + } + currentOffset = fragmentEnd; } } diff --git a/lib/src/core/render_media.dart b/lib/src/core/render_media.dart index cc5328a..25eab5e 100644 --- a/lib/src/core/render_media.dart +++ b/lib/src/core/render_media.dart @@ -8,12 +8,14 @@ import '../model/node.dart'; /// /// This allows users to plug in their own video/audio player implementations /// (video_player, chewie, just_audio, etc.) +/// Builder for media widgets. typedef MediaWidgetBuilder = Widget Function( BuildContext context, MediaInfo mediaInfo, ); /// Information about a media element +/// Metadata for media items. class MediaInfo { /// Media type (audio or video) final MediaType type; @@ -89,6 +91,7 @@ class MediaInfo { } /// Type of media element +/// Supported media types. enum MediaType { audio, video, @@ -108,6 +111,7 @@ extension AtomicNodeMediaExtension on AtomicNode { /// This is used when no custom MediaWidgetBuilder is provided. /// For actual video/audio playback, users should provide their own /// implementation using video_player, chewie, just_audio, etc. +/// Default widget for media playback. class DefaultMediaWidget extends StatefulWidget { final MediaInfo mediaInfo; final VoidCallback? onTap; diff --git a/lib/src/core/render_ruby.dart b/lib/src/core/render_ruby.dart index 40f25ed..1472c63 100644 --- a/lib/src/core/render_ruby.dart +++ b/lib/src/core/render_ruby.dart @@ -12,7 +12,6 @@ import 'package:flutter/widgets.dart'; /// 漢字かんじ /// ``` /// -/// Reference: doc3.md - "Requirement 4: Japanese Ruby/Furigana Support" class RubySpan extends WidgetSpan { RubySpan({ required String baseText, @@ -67,7 +66,6 @@ class RubyTextWidget extends LeafRenderObjectWidget { /// This renders base text with smaller ruby text above it, /// maintaining consistent line height and proper baseline alignment. /// -/// Reference: doc3.md - "class RenderRubyText extends RenderBox" class RenderRubyText extends RenderBox { String _baseText; String _rubyText; diff --git a/lib/src/core/render_table.dart b/lib/src/core/render_table.dart index 5bd70ac..f9ebca6 100644 --- a/lib/src/core/render_table.dart +++ b/lib/src/core/render_table.dart @@ -3,9 +3,17 @@ import 'package:flutter/rendering.dart'; import '../model/node.dart'; +/// Maximum colspan/rowspan value accepted from HTML attributes. +/// +/// Browsers cap at 1 000; values beyond this trigger [List.generate] calls +/// that allocate millions of grid cells, causing OOM on adversarial or +/// malformed markup. +const int _kMaxSpan = 1000; + /// Table display strategy /// /// Determines how tables that are wider than screen are handled +/// Strategy for table layout calculation. enum TableStrategy { /// Fit table to screen width (may truncate content) fitWidth, @@ -28,7 +36,7 @@ enum TableStrategy { /// (columns narrower than [minColumnWidth]) and switches to horizontal scroll /// to prevent unreadable text. /// -/// Reference: doc3.md - "Requirement 2: Table Horizontal Scroll & Auto Scale" +/// Wrapper for smart table rendering. class SmartTableWrapper extends StatelessWidget { /// The table node to render final TableNode tableNode; @@ -74,111 +82,43 @@ class SmartTableWrapper extends StatelessWidget { @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - // Build the table widget - final table = _buildTable(context); - - // Calculate effective strategy based on constraints - final effectiveStrategy = _calculateEffectiveStrategy(constraints); - - // Apply strategy based on configuration - switch (effectiveStrategy) { - case TableStrategy.fitWidth: - // Render table with width constraints - return ConstrainedBox( - constraints: BoxConstraints(maxWidth: constraints.maxWidth), - child: table, - ); - - case TableStrategy.autoScale: - // Use FittedBox to scale down if needed - return FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.topLeft, - child: table, - ); - - case TableStrategy.horizontalScroll: - // Enable horizontal scrolling - return _buildScrollableTable(table); - } - }, - ); - } - - /// Calculate the effective strategy based on table size and constraints - /// - /// If the requested strategy would result in columns that are too narrow, - /// automatically switch to horizontal scroll to prevent unreadable text. - TableStrategy _calculateEffectiveStrategy(BoxConstraints constraints) { - if (strategy == TableStrategy.horizontalScroll) { - return strategy; // Already using scroll, no change needed - } - - // Estimate table width based on column count - final columnCount = _estimateColumnCount(); - if (columnCount == 0) return strategy; - - final availableWidth = constraints.maxWidth; - final estimatedColumnWidth = availableWidth / columnCount; - - // If columns would be too narrow, switch to horizontal scroll - if (estimatedColumnWidth < minColumnWidth) { - return TableStrategy.horizontalScroll; - } - - // For autoScale, also check if scale factor would be too small - if (strategy == TableStrategy.autoScale) { - // Estimate natural table width (rough estimate based on column count and min width) - final naturalWidth = columnCount * minColumnWidth * 2; // Assume 2x min as natural - final scaleFactor = availableWidth / naturalWidth; - - if (scaleFactor < minScaleFactor) { - return TableStrategy.horizontalScroll; - } - } - - return strategy; - } - - /// Estimate the number of columns in the table - int _estimateColumnCount() { - // TableNode stores rows as children - final rows = tableNode.children.whereType(); - if (rows.isEmpty) return 0; + // Strategy is resolved statically at build time — no LayoutBuilder. + // + // LayoutBuilder cannot answer intrinsic-dimension queries propagated by an + // outer IntrinsicHeight (e.g. a row in a parent table). Resolving the + // strategy without LayoutBuilder keeps nested tables compatible with + // IntrinsicHeight while still honouring the caller's chosen strategy. + switch (strategy) { + case TableStrategy.fitWidth: + return _buildTable(useIntrinsicWidth: false); + + case TableStrategy.autoScale: + // FittedBox gives the child unconstrained width; IntrinsicWidth then + // constrains _TableLayout to its natural content width so FittedBox + // can measure and scale it correctly. + return FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topLeft, + child: _buildTable(useIntrinsicWidth: true), + ); - // Find the maximum number of cells in any row (accounting for colspan) - int maxColumns = 0; - for (final row in rows) { - int rowColumns = 0; - // TableRowNode stores cells as children - final cells = row.children.whereType(); - for (final cell in cells) { - rowColumns += cell.colspan; - } - if (rowColumns > maxColumns) { - maxColumns = rowColumns; - } + case TableStrategy.horizontalScroll: + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: _buildTable(useIntrinsicWidth: true), + ); } - return maxColumns; - } - - Widget _buildScrollableTable(Widget table) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - physics: const BouncingScrollPhysics(), - child: table, - ); } - Widget _buildTable(BuildContext context) { + Widget _buildTable({required bool useIntrinsicWidth}) { return HyperTable( tableNode: tableNode, baseStyle: baseStyle, onLinkTap: onLinkTap, selectable: selectable, cellContentBuilder: cellContentBuilder, + useIntrinsicWidth: useIntrinsicWidth, ); } } @@ -195,6 +135,7 @@ class SmartTableWrapper extends StatelessWidget { /// /// Note: Uses custom layout instead of Flutter's Table widget /// because Table doesn't support colspan/rowspan. +/// Widget representing a table. class HyperTable extends StatelessWidget { final TableNode tableNode; final TextStyle? baseStyle; @@ -217,6 +158,14 @@ class HyperTable extends StatelessWidget { /// tables, paragraphs, images) that cannot be represented as inline spans. final Widget Function(TableCellNode)? cellContentBuilder; + /// When true, wraps the table widget in [IntrinsicWidth] so it can be placed + /// inside an unbounded-width parent (horizontal scroll, FittedBox). + /// + /// Must be false when the parent supplies bounded width constraints — + /// [IntrinsicWidth] constrains the child to its natural content width, which + /// would prevent the table from expanding to fill its parent. + final bool useIntrinsicWidth; + const HyperTable({ super.key, required this.tableNode, @@ -227,6 +176,7 @@ class HyperTable extends StatelessWidget { this.cellPadding = const EdgeInsets.all(8.0), this.selectable = true, this.cellContentBuilder, + this.useIntrinsicWidth = false, }); @override @@ -244,6 +194,7 @@ class HyperTable extends StatelessWidget { borderWidth: borderWidth, cellPadding: cellPadding, cellBuilder: _buildCellContent, + useIntrinsicWidth: useIntrinsicWidth, ); // Wrap with SelectionArea for cross-cell text selection @@ -367,13 +318,15 @@ class _TableGrid { return _TableGrid(cells: [], columnCount: 0, rowCount: 0); } - // Calculate column count (max cells in any row, considering colspan) + // Calculate column count (max cells in any row, considering colspan). + // Clamp per-cell colspan to [_kMaxSpan] so adversarial or malformed HTML + // (e.g. colspan="1000000") cannot trigger an OOM List.generate call. int maxCols = 0; for (final row in rows) { int colCount = 0; for (final cell in row.children) { if (cell is TableCellNode) { - colCount += cell.colspan; + colCount += cell.colspan.clamp(1, _kMaxSpan); } } if (colCount > maxCols) maxCols = colCount; @@ -398,8 +351,8 @@ class _TableGrid { if (colIdx >= maxCols) break; - final colspan = child.colspan; - final rowspan = child.rowspan; + final colspan = child.colspan.clamp(1, _kMaxSpan); + final rowspan = child.rowspan.clamp(1, _kMaxSpan); // Create primary cell final gridCell = _GridCell( @@ -470,39 +423,39 @@ class _TableLayout extends StatelessWidget { final EdgeInsets cellPadding; final Widget Function(TableCellNode) cellBuilder; + /// When true, the table is wrapped in [IntrinsicWidth]. + /// + /// Required when the parent gives unbounded width (horizontal scroll, + /// FittedBox). Must be false under bounded width so the table can expand + /// to fill the available space rather than collapsing to content width. + /// + /// Formerly determined via [LayoutBuilder], which blocked intrinsic-dimension + /// queries from outer [IntrinsicHeight] widgets (nested-table rows). The flag + /// is now set statically by [SmartTableWrapper] based on the chosen strategy. + final bool useIntrinsicWidth; + const _TableLayout({ required this.grid, required this.borderColor, required this.borderWidth, required this.cellPadding, required this.cellBuilder, + required this.useIntrinsicWidth, }); @override Widget build(BuildContext context) { - // Wrap in LayoutBuilder to handle unbounded constraints - // This fixes "NEEDS-LAYOUT" error when used in horizontal scroll - return LayoutBuilder( - builder: (context, constraints) { - final child = Container( - decoration: BoxDecoration( - border: Border.all(color: borderColor, width: borderWidth), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: _buildRows(), - ), - ); - - // If width is unbounded (e.g., in horizontal scroll), - // wrap in IntrinsicWidth to provide bounded constraints - if (constraints.maxWidth == double.infinity) { - return IntrinsicWidth(child: child); - } - - return child; - }, + final child = Container( + decoration: BoxDecoration( + border: Border.all(color: borderColor, width: borderWidth), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: _buildRows(), + ), ); + + return useIntrinsicWidth ? IntrinsicWidth(child: child) : child; } List _buildRows() { @@ -624,7 +577,7 @@ class _TableLayout extends StatelessWidget { /// This is an optimized table layout that properly calculates /// minimum and maximum intrinsic widths for smart resizing. /// -/// Reference: doc3.md - "RenderCustomHtmlTable" +/// RenderObject for table layout. class RenderHyperTable extends RenderBox with ContainerRenderObjectMixin, @@ -854,6 +807,7 @@ class RenderHyperTable extends RenderBox } /// Parent data for table cells +/// Parent data for table children. class TableParentData extends ContainerBoxParentData { /// Column index int column = 0; diff --git a/lib/src/core/span_converter.dart b/lib/src/core/span_converter.dart index 5163112..735441f 100644 --- a/lib/src/core/span_converter.dart +++ b/lib/src/core/span_converter.dart @@ -9,9 +9,11 @@ import 'render_ruby.dart'; import 'render_table.dart'; /// Callback for handling link taps + /// Callback when a link is tapped. typedef LinkTapCallback = void Function(String url); /// Callback for handling image loading + /// Builder for custom images. typedef ImageBuilder = Widget Function(String src, String? alt, double? width, double? height); /// HtmlToSpanConverter - Core Engine @@ -26,7 +28,7 @@ typedef ImageBuilder = Widget Function(String src, String? alt, double? width, d /// - Smooth text selection without interruption /// - Perfect compatibility with Flutter text engine /// -/// Reference: doc3.md - "2.1 Paradigm Shift: Single InlineSpan Tree" + /// Converter that transforms HTML nodes to Flutter spans. class HtmlToSpanConverter { /// Base text style final TextStyle baseStyle; diff --git a/lib/src/model/computed_style.dart b/lib/src/model/computed_style.dart index 3b3f7b3..c66ac09 100644 --- a/lib/src/model/computed_style.dart +++ b/lib/src/model/computed_style.dart @@ -3,7 +3,6 @@ import 'package:flutter/painting.dart'; import 'package:vector_math/vector_math_64.dart' show Matrix4; /// Display type for CSS display property -/// Reference: doc1.txt - ComputedStyle class enum DisplayType { /// Block-level element (div, p, h1-h6) block, @@ -17,7 +16,7 @@ enum DisplayType { /// Flex container flex, - /// Grid container (future) + /// Grid container grid, /// Table element @@ -268,11 +267,10 @@ class HyperTransition { 'HyperTransition($property, ${duration}ms, $timingFunction, ${delay}ms)'; } -/// Computed style for a UDT node -/// All CSS properties are resolved to final values here +/// Computed style for a UDT node. /// -/// Reference: doc1.txt - "1.1. Cấu trúc Dữ liệu Style (The Style Node)" -/// Each Node in UDT will have a ComputedStyle object +/// All CSS properties are resolved to their final values here. +/// Every node in the Unified Document Tree owns exactly one [ComputedStyle]. class ComputedStyle { /// Track which properties have been explicitly set (not inherited) /// This is crucial for proper CSS inheritance @@ -658,7 +656,6 @@ class ComputedStyle { /// Inherit inheritable properties from parent /// - /// Reference: doc1.txt - "1.2. Quy trình Resolve" /// Properties like color, font-family are inherited from parent Node /// margin, padding are NOT inherited void inheritFrom(ComputedStyle parent) { diff --git a/lib/src/model/fragment.dart b/lib/src/model/fragment.dart index 10c8e20..92c9f44 100644 --- a/lib/src/model/fragment.dart +++ b/lib/src/model/fragment.dart @@ -5,10 +5,10 @@ import 'node.dart'; /// Fragment type for inline layout /// -/// Reference: doc1.txt - "Chiến lược Chunking & Line Building" -/// Content is divided into Fragments (Mảnh): -/// - Text Fragment: A phrase with the same style -/// - Atomic Fragment: An icon, image, or media (treated as a special character) +/// Content is divided into fragments, each measured independently and then +/// arranged into lines: +/// - Text Fragment: A run of text sharing the same style +/// - Atomic Fragment: An image, video, or other replaced element enum FragmentType { /// Text content fragment text, @@ -28,11 +28,11 @@ enum FragmentType { /// During layout, the content is broken into Fragments, each measured /// independently, then arranged into lines. /// -/// Reference: doc1.txt - "Quy trình 4 bước của thuật toán" -/// Step 1: Tokenization - Break into Fragments -/// Step 2: Measuring - Measure each Fragment -/// Step 3: Line Breaking - Arrange into lines -/// Step 4: Baseline Alignment +/// Layout follows a 4-step algorithm: +/// Step 1: Tokenization — break into Fragments +/// Step 2: Measuring — measure each Fragment +/// Step 3: Line breaking — arrange into lines +/// Step 4: Baseline alignment class Fragment { /// Fragment type final FragmentType type; @@ -170,7 +170,6 @@ class Fragment { /// A line of fragments after layout /// -/// Reference: doc1.txt - "Bước 3: Xây dựng dòng (Line Breaking)" class LineInfo { /// Fragments in this line final List fragments = []; @@ -226,13 +225,17 @@ class LineInfo { } /// Number of characters in this line + /// + /// Counts text and ruby base-text characters, plus 1 for each line break. int get characterCount { int count = 0; for (final frag in fragments) { - if (frag.type == FragmentType.text && frag.text != null) { + if ((frag.type == FragmentType.text || + frag.type == FragmentType.ruby) && + frag.text != null) { count += frag.text!.length; } else if (frag.type == FragmentType.lineBreak) { - count += 1; // Line break counts as 1 character + count += 1; } } return count; diff --git a/lib/src/model/node.dart b/lib/src/model/node.dart index 41db703..1c25079 100644 --- a/lib/src/model/node.dart +++ b/lib/src/model/node.dart @@ -4,7 +4,7 @@ import 'computed_style.dart'; /// Node type in the Unified Document Tree (UDT) /// -/// Reference: doc1.txt - "Mỗi Node trong UDT sẽ có: Type (Block/Inline), Attributes (Styles), và Children" +/// Each node has a type, computed style, and optional children. enum NodeType { /// Document root document, @@ -45,8 +45,6 @@ enum NodeType { /// The UDT is the "source of truth" - all input formats (HTML, Delta, Markdown) /// are converted to UDT before rendering. /// -/// Reference: doc1.txt - "Unified Document Tree (UDT)" -/// Reference: doc3.md - Section 2 "Core Architecture" abstract class UDTNode { /// Node type final NodeType type; @@ -174,7 +172,6 @@ class DocumentNode extends UDTNode { /// Block-level element node (div, p, h1-h6, blockquote, etc.) /// -/// Reference: doc1.txt - "Block Formatting Context (BFC)" class BlockNode extends UDTNode { BlockNode({ required String super.tagName, @@ -235,7 +232,6 @@ class BlockNode extends UDTNode { /// Inline element node (span, a, strong, em, etc.) /// -/// Reference: doc1.txt - "Inline Formatting Context (IFC)" class InlineNode extends UDTNode { InlineNode({ required String super.tagName, @@ -332,7 +328,6 @@ class LineBreakNode extends UDTNode { /// These elements have intrinsic dimensions and are treated as /// single units during layout (like a large character) /// -/// Reference: doc1.txt - "Atomic Fragment" class AtomicNode extends UDTNode { /// Source URL for media elements final String? src; @@ -428,7 +423,6 @@ class AtomicNode extends UDTNode { /// Ruby annotation node (for Japanese Furigana) /// -/// Reference: doc3.md - Section "Requirement 4: Japanese Ruby/Furigana Support" class RubyNode extends UDTNode { /// Base text (Kanji) final String baseText; @@ -451,7 +445,6 @@ class RubyNode extends UDTNode { /// Table node /// -/// Reference: doc3.md - Section "Requirement 2: Table Horizontal Scroll" class TableNode extends UDTNode { TableNode({ super.attributes, diff --git a/lib/src/parser/adapter.dart b/lib/src/parser/adapter.dart index 71407ed..a3619cc 100644 --- a/lib/src/parser/adapter.dart +++ b/lib/src/parser/adapter.dart @@ -8,7 +8,7 @@ enum InputType { /// Quill Delta JSON delta, - /// Markdown string (future) + /// Markdown string markdown, } diff --git a/lib/src/parser/html/html_adapter.dart b/lib/src/parser/html/html_adapter.dart index 027ac2c..fecb9bf 100644 --- a/lib/src/parser/html/html_adapter.dart +++ b/lib/src/parser/html/html_adapter.dart @@ -107,6 +107,20 @@ class HtmlAdapter { return buffer.toString(); } + /// Parses `@keyframes` rules from all `