diff --git a/.changeset/config.json b/.changeset/config.json index 46fa368d8c..f799187208 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,16 +1,17 @@ { "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", - "changelog": "@changesets/cli/changelog", - "commit": false, - "fixed": [["react-email-starter", "create-email"]], - "linked": [], "access": "public", "baseBranch": "main", - "updateInternalDependencies": "patch", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [["react-email", "@react-email/ui"]], "ignore": [ - "@benchmarks/preview-server", + "@benchmarks/ui", "@benchmarks/tailwind-component", + "playground", "demo", + "email-dev", "web" - ] + ], + "updateInternalDependencies": "patch" } diff --git a/benchmarks/tailwind-component/tailwind.config.js b/.github/CODEOWNERS similarity index 100% rename from benchmarks/tailwind-component/tailwind.config.js rename to .github/CODEOWNERS diff --git a/.github/ISSUE_TEMPLATE/1.bug_report.yml b/.github/ISSUE_TEMPLATE/1.bug_report.yml index 8765ea8d2b..db68fc94f5 100644 --- a/.github/ISSUE_TEMPLATE/1.bug_report.yml +++ b/.github/ISSUE_TEMPLATE/1.bug_report.yml @@ -2,6 +2,10 @@ name: Bug Report description: Create a bug report for React Email labels: ["Type: Bug"] body: + - type: input + attributes: + label: What versions are you using? (if relevant) + value: "@react-email/components@x.y.z, react-email@x.y.z, etc." - type: textarea attributes: label: Describe the Bug @@ -10,45 +14,47 @@ body: required: true - type: dropdown attributes: - label: Which package is affected (leave empty if unsure) + label: What is affected (leave empty if unsure) multiple: true options: - - "@react-email/body" - - "@react-email/button" - - "@react-email/column" - - "@react-email/components" - - "@react-email/container" - - "@react-email/font" - - "@react-email/head" - - "@react-email/heading" - - "@react-email/hr" - - "@react-email/html" - - "@react-email/img" - - "@react-email/link" - - "@react-email/preview" - - "@react-email/render" - - "@react-email/row" - - "@react-email/section" - - "@react-email/tailwind" - - "@react-email/text" - - "client" - - "create-email" - - "demo" - - "docs" - - "examples" - - "react-email" - - "web" - - type: input + - "Preview Server" + - "CLI" + - "Html Component" + - "Body Component" + - "Head Component" + - "Button Component" + - "Container Component" + - "CodeBlock Component" + - "CodeInline Component" + - "Column Component" + - "Row Component" + - "Font Component" + - "Heading Component" + - "Hr Component" + - "Img Component" + - "Link Component" + - "Markdown Component" + - "Preview Component" + - "Section Component" + - "Tailwind Component" + - "Text Component" + - "Render Utility" + - "@react-email/components package" + - "npx create-email" + - "Demo" + - "Website" + - "Examples" + - type: textarea attributes: label: Link to the code that reproduces this issue - description: | - A link to a GitHub repository minimal reproduction. A minimal reproduction code is really helpful to understand the issue. + value: | + A link to a GitHub repository minimal reproduction. Not your entire project, just the code necessary to reproduce the issue. Try going from the starter `npx create-email@latest` and adding only what's needed to cause the issue. If you don't share a reproduction, we might close the issue or it will take significantly longer for things to get sorted out. validations: required: true - type: textarea attributes: label: To Reproduce - description: Steps to reproduce the behavior, please provide a clear description of how to reproduce the issue, based on the linked minimal reproduction. Screenshots can be provided in the issue body below. If using code blocks, make sure that [syntax highlighting is correct](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks#syntax-highlighting) and double check that the rendered preview is not broken. + value: Steps to reproduce the behavior, please provide a clear description of how to reproduce the issue, based on the linked minimal reproduction. Screenshots can be provided in the issue body below. If using code blocks, make sure that [syntax highlighting is correct](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks#syntax-highlighting) and double check that the rendered preview is not broken. validations: required: true - type: textarea @@ -67,3 +73,4 @@ body: attributes: label: What's your node version? (if relevant) description: "Please specify the exact version." + diff --git a/.github/workflows/bump-canary.yml b/.github/workflows/bump-canary.yml deleted file mode 100644 index 93b064cd2c..0000000000 --- a/.github/workflows/bump-canary.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Bump canary - -on: - push: - branches: - - canary - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -jobs: - release: - name: Bump for canary - runs-on: ubuntu-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v4 - - - name: Setup Node.js 22 - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Enable Corepack - id: pnpm-setup - run: | - corepack enable - corepack prepare pnpm@9.15.0 --activate - pnpm config set script-shell "/usr/bin/bash" - echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" - - - name: pnpm Cache - uses: buildjet/cache@v4 - with: - path: ${{ steps.pnpm-setup.outputs.pnpm_cache_dir }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install packages - run: pnpm install --frozen-lockfile - - - name: Enter prerelease mode - continue-on-error: true - run: pnpm canary:enter - - - name: Create Release Pull Request - uses: changesets/action@v1 - with: - version: pnpm run version - title: "chore: Bump for release" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/bump-stable.yml b/.github/workflows/bump-stable.yml deleted file mode 100644 index d32139032a..0000000000 --- a/.github/workflows/bump-stable.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Bump - -on: - push: - branches: - - main - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -jobs: - release: - name: Bump - runs-on: ubuntu-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v4 - - - name: Setup Node.js 22 - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Enable Corepack - id: pnpm-setup - run: | - corepack enable - corepack prepare pnpm@9.15.0 --activate - pnpm config set script-shell "/usr/bin/bash" - echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" - - - name: pnpm Cache - uses: buildjet/cache@v4 - with: - path: ${{ steps.pnpm-setup.outputs.pnpm_cache_dir }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install packages - run: pnpm install --frozen-lockfile - - - name: Exit prerelease mode - continue-on-error: true - run: pnpm canary:exit - - - name: Create Release Pull Request - uses: changesets/action@v1 - with: - version: pnpm run version - title: "chore: Bump for release" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml new file mode 100644 index 0000000000..f89cb8dd65 --- /dev/null +++ b/.github/workflows/bump.yml @@ -0,0 +1,47 @@ +name: Bump +on: + push: + branches: + - canary +concurrency: ${{ github.workflow }}-${{ github.ref }} +jobs: + bump: + timeout-minutes: 30 + runs-on: depot-ubuntu-22.04-2 + permissions: + contents: write + pull-requests: write + container: + image: node:24 + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + - name: Checkout Repo + uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 + with: + fetch-depth: 1 + token: ${{ steps.app-token.outputs.token }} + - run: git config --global --add safe.directory $GITHUB_WORKSPACE + - name: pnpm setup + uses: pnpm/action-setup@738f428026a1f5a72398de22aeed83d859c4a660 + - name: Install packages + run: pnpm install --frozen-lockfile --prefer-offline + - name: Configure version bump git user + run: | + git config user.name "resend-version-bump[bot]" + git config user.email "277115511+resend-version-bump[bot]@users.noreply.github.com" + - name: Create "version packages" pull request + uses: changesets/action@6a0a831ff30acef54f2c6aa1cbbc1096b066edaf + with: + version: pnpm run version + title: "chore(root): version packages" + commitMode: git-cli + setupGitUser: false + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000000..a1db0ddd7c --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,52 @@ +name: E2E Tests +on: + push: + branches: + - main + - canary + pull_request: +permissions: + contents: read + pull-requests: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + e2e: + # Org secrets in step env: push + same-repo PRs only; fork PRs evaluate to empty (explicit for review). + timeout-minutes: 45 + runs-on: depot-ubuntu-22.04-8 + container: + image: mcr.microsoft.com/playwright:v1.59.1-noble + steps: + - name: Checkout Repo + uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 + with: + fetch-depth: 1 + - run: git config --global --add safe.directory $GITHUB_WORKSPACE + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 24 + - name: pnpm setup + uses: pnpm/action-setup@738f428026a1f5a72398de22aeed83d859c4a660 + - name: Install packages + run: pnpm install --frozen-lockfile --prefer-offline + - name: Install Playwright browsers + run: pnpm exec playwright install chromium --with-deps + - name: Run Build + run: pnpm build + env: + REDIS_URL: redis://localhost:6379 + SPAM_ASSASSIN_HOST: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.SPAM_ASSASSIN_HOST || '' }} + SPAM_ASSASSIN_PORT: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.SPAM_ASSASSIN_PORT || '' }} + TURBO_TOKEN: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.TURBO_TOKEN || '' }} + TURBO_TEAM: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.TURBO_TEAM || '' }} + - name: Run Tailwind integration tests + run: pnpm turbo test:e2e + env: + REDIS_URL: redis://localhost:6379 + SPAM_ASSASSIN_HOST: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.SPAM_ASSASSIN_HOST || '' }} + SPAM_ASSASSIN_PORT: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.SPAM_ASSASSIN_PORT || '' }} + TURBO_TOKEN: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.TURBO_TOKEN || '' }} + TURBO_TEAM: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.TURBO_TEAM || '' }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000000..67611c78f1 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,32 @@ +name: Lint +on: + push: + branches: + - main + - canary + pull_request: +permissions: + contents: read + pull-requests: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + lint: + timeout-minutes: 20 + runs-on: depot-ubuntu-22.04-2 + container: + image: node:24-slim + steps: + - name: Checkout Repo + uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 + with: + fetch-depth: 1 + - name: pnpm setup + uses: pnpm/action-setup@738f428026a1f5a72398de22aeed83d859c4a660 + - name: Install packages + run: pnpm install --frozen-lockfile --prefer-offline + - name: Run Lint + run: pnpm lint + env: + SKIP_ENV_VALIDATION: true diff --git a/.github/workflows/pin-dependencies-check.yml b/.github/workflows/pin-dependencies-check.yml new file mode 100644 index 0000000000..9db1a8068c --- /dev/null +++ b/.github/workflows/pin-dependencies-check.yml @@ -0,0 +1,30 @@ +name: Pin Dependencies Check +on: + push: + branches: + - main + - canary + pull_request: +permissions: + contents: read + pull-requests: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + pin-dependencies-check: + timeout-minutes: 15 + runs-on: depot-ubuntu-22.04-2 + container: + image: node:24-slim + steps: + - name: Checkout Repo + uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 + with: + fetch-depth: 1 + - name: pnpm setup + uses: pnpm/action-setup@738f428026a1f5a72398de22aeed83d859c4a660 + - name: Install root dependencies only + run: pnpm install --frozen-lockfile --filter . --ignore-scripts + - name: Check for pinned dependencies + run: pnpm exec tsx ./scripts/check-dependency-versions.ts diff --git a/.github/workflows/preview-release.yml b/.github/workflows/preview-release.yml new file mode 100644 index 0000000000..26f84ff80c --- /dev/null +++ b/.github/workflows/preview-release.yml @@ -0,0 +1,45 @@ +name: Preview Release +on: + pull_request: +permissions: + contents: read + pull-requests: write +concurrency: ${{ github.workflow }}-${{ github.ref }} +jobs: + preview-release: + # Build secrets: same-repo PRs only; fork PRs evaluate to empty (explicit for review). + timeout-minutes: 45 + runs-on: depot-ubuntu-22.04-8 + permissions: + contents: write + pull-requests: write + container: + image: node:24 + steps: + - name: Checkout Repo + uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 + with: + fetch-depth: 2 + - run: git config --global --add safe.directory $GITHUB_WORKSPACE + - name: pnpm setup + uses: pnpm/action-setup@738f428026a1f5a72398de22aeed83d859c4a660 + - name: Install packages + run: pnpm install --frozen-lockfile --prefer-offline + - name: Find changed packages + id: changed_packages + uses: tj-actions/changed-files@3c4bc6fa0ca4718d438e0a4bd3ea81fbb0e6e2be + with: + files: packages/** + dir_names: true + dir_names_max_depth: 2 + - name: Run Build + if: steps.changed_packages.outputs.all_changed_and_modified_files != '' + run: pnpm build + env: + SPAM_ASSASSIN_HOST: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.SPAM_ASSASSIN_HOST || '' }} + SPAM_ASSASSIN_PORT: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.SPAM_ASSASSIN_PORT || '' }} + TURBO_TOKEN: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.TURBO_TOKEN || '' }} + TURBO_TEAM: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.TURBO_TEAM || '' }} + - name: Publish changed packages to pkg.pr.new + if: steps.changed_packages.outputs.all_changed_and_modified_files != '' + run: pnpm pkg-pr-new publish ${{ steps.changed_packages.outputs.all_changed_and_modified_files }} --pnpm diff --git a/.github/workflows/pull-request-title-check.yml b/.github/workflows/pull-request-title-check.yml new file mode 100644 index 0000000000..2c86103283 --- /dev/null +++ b/.github/workflows/pull-request-title-check.yml @@ -0,0 +1,26 @@ +name: Pull Request Title Check +on: + pull_request: + types: [opened, edited, synchronize] +permissions: + pull-requests: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + pull-request-title-check: + timeout-minutes: 15 + runs-on: depot-ubuntu-22.04-2 + container: + image: node:24-slim + steps: + - name: Checkout Repo + uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 + with: + fetch-depth: 1 + - name: pnpm setup + uses: pnpm/action-setup@738f428026a1f5a72398de22aeed83d859c4a660 + - name: Install packages + run: pnpm install --frozen-lockfile --prefer-offline + - name: Check pull request title + run: pnpm tsx ./scripts/pull-request-title-check.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..d33e94b5db --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: Release +on: + push: + branches: + - main + - canary +permissions: + contents: read +concurrency: ${{ github.workflow }}-${{ github.ref }} +jobs: + release: + # npm trusted publishing (OIDC) is only supported on GitHub-hosted `ubuntu-latest` runners. + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + id-token: write + contents: write + container: + image: node:24 + steps: + - name: Checkout Repo + uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 + with: + fetch-depth: 1 + - run: git config --global --add safe.directory $GITHUB_WORKSPACE + - name: pnpm setup + uses: pnpm/action-setup@738f428026a1f5a72398de22aeed83d859c4a660 + - name: Install packages + run: pnpm install --frozen-lockfile --prefer-offline + - name: Publish release + run: node --import tsx/esm ./scripts/release.mts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/sync-prs-to-linear.yml b/.github/workflows/sync-prs-to-linear.yml new file mode 100644 index 0000000000..7611962a0f --- /dev/null +++ b/.github/workflows/sync-prs-to-linear.yml @@ -0,0 +1,24 @@ +name: Sync Open PRs to Linear + +on: + schedule: + - cron: '0 10 * * *' + workflow_dispatch: + +permissions: + contents: none + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + pull-requests: write + issues: write + + steps: + - uses: resend/public-shared-workflows/.github/actions/sync_prs_to_linear@6adbbae7541681ead204faab5d4e5ea9bebca4da + with: + linear-api-key: ${{ secrets.LINEAR_API_KEY }} + linear-team-id: ${{ secrets.LINEAR_TEAM_ID }} + github-token: ${{ github.token }} diff --git a/.github/workflows/sync-skills.yml b/.github/workflows/sync-skills.yml new file mode 100644 index 0000000000..cdcaf098e1 --- /dev/null +++ b/.github/workflows/sync-skills.yml @@ -0,0 +1,27 @@ +name: Sync Skills + +on: + push: + branches: [main] + paths: ['skills/**'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + sync: + timeout-minutes: 10 + runs-on: depot-ubuntu-22.04-2 + steps: + - name: Trigger sync on resend-skills + env: + GH_TOKEN: ${{ secrets.SYNC_SKILLS_TO_RESEND_SKILLS }} + run: | + gh workflow run sync-from-repo.yml \ + --repo resend/resend-skills \ + --field repo=react-email \ + --field skill-path=skills/react-email \ + --field skill-name=react-email \ + --field sha=${{ github.sha }} \ + --field reviewer=${{ github.actor }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 496550d323..4ebbf028e9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,150 +1,51 @@ -name: rsnd +name: Tests on: push: branches: - main + - canary pull_request: +permissions: + contents: read + pull-requests: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - lint: - runs-on: buildjet-4vcpu-ubuntu-2204 + tests: + # Org secrets in step env: push + same-repo PRs only; fork PRs evaluate to empty (explicit for review). + timeout-minutes: 45 + runs-on: depot-ubuntu-22.04-8 container: - image: node:22 + image: mcr.microsoft.com/playwright:v1.59.1-noble steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Enable Corepack - id: pnpm-setup - run: | - corepack enable - corepack prepare pnpm@9.15.0 --activate - pnpm config set script-shell "/usr/bin/bash" - echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" - - - name: pnpm Cache - uses: buildjet/cache@v4 + - name: Checkout Repo + uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 with: - path: ${{ steps.pnpm-setup.outputs.pnpm_cache_dir }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install packages - run: pnpm install --frozen-lockfile - - - name: Run Build - run: pnpm build - - - name: Run Lint - run: pnpm lint - - test: - runs-on: buildjet-4vcpu-ubuntu-2204 - container: - image: node:22 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Enable Corepack - id: pnpm-setup - run: | - corepack enable - corepack prepare pnpm@9.15.0 --activate - pnpm config set script-shell "/usr/bin/bash" - echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" - - - name: pnpm Cache - uses: buildjet/cache@v4 + fetch-depth: 1 + - run: git config --global --add safe.directory $GITHUB_WORKSPACE + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: - path: ${{ steps.pnpm-setup.outputs.pnpm_cache_dir }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - + node-version: 24 + - name: pnpm setup + uses: pnpm/action-setup@738f428026a1f5a72398de22aeed83d859c4a660 - name: Install packages - run: pnpm install --frozen-lockfile - + run: pnpm install --frozen-lockfile --prefer-offline - name: Run Build run: pnpm build - + env: + REDIS_URL: redis://localhost:6379 + SPAM_ASSASSIN_HOST: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.SPAM_ASSASSIN_HOST || '' }} + SPAM_ASSASSIN_PORT: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.SPAM_ASSASSIN_PORT || '' }} + TURBO_TOKEN: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.TURBO_TOKEN || '' }} + TURBO_TEAM: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.TURBO_TEAM || '' }} - name: Run Tests run: pnpm test - env: - SPAM_ASSASSIN_HOST: ${{ secrets.SPAM_ASSASSIN_HOST }} - SPAM_ASSASSIN_PORT: ${{ secrets.SPAM_ASSASSIN_PORT }} - - build: - runs-on: buildjet-4vcpu-ubuntu-2204 - container: - image: node:22 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Enable Corepack - id: pnpm-setup - run: | - corepack enable - corepack prepare pnpm@9.15.0 --activate - pnpm config set script-shell "/usr/bin/bash" - echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" - - - name: pnpm Cache - uses: buildjet/cache@v4 - with: - path: ${{ steps.pnpm-setup.outputs.pnpm_cache_dir }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install packages - run: pnpm install --frozen-lockfile - - - name: Run Build - run: pnpm build - - dependencies: - runs-on: buildjet-4vcpu-ubuntu-2204 - container: - image: node:18 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Check for pinned dependencies - run: | - node -e ' - const fs = require("fs"); - const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); - const errors = []; - - function isPinned(version) { - if (version.startsWith("workspace:")) { - return true; - } - if (version.startsWith("npm:")) { - return true; - } - return /^\d+\.\d+\.\d+$|^[a-z]+:[a-z]+@\d+$/.test(version); - } - - for (const [dep, version] of Object.entries(pkg.dependencies || {})) { - if (!isPinned(version)) { - errors.push(`Dependency "${dep}" is not pinned: "${version}"`); - } - } - - for (const [dep, version] of Object.entries(pkg.devDependencies || {})) { - if (!isPinned(version)) { - errors.push(`Dev dependency "${dep}" is not pinned: "${version}"`); - } - } - - if (errors.length > 0) { - console.error(`\n${errors.join("\n")}\n`); - process.exit(1); - } else { - console.log("All dependencies are pinned."); - } - ' + env: + PLAYWRIGHT_BROWSERS_PATH: /ms-playwright + REDIS_URL: redis://localhost:6379 + SPAM_ASSASSIN_HOST: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.SPAM_ASSASSIN_HOST || '' }} + SPAM_ASSASSIN_PORT: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.SPAM_ASSASSIN_PORT || '' }} + TURBO_TOKEN: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.TURBO_TOKEN || '' }} + TURBO_TEAM: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.TURBO_TEAM || '' }} diff --git a/.gitignore b/.gitignore index d76b05d3b1..9c6d3b46be 100644 --- a/.gitignore +++ b/.gitignore @@ -7,11 +7,17 @@ node_modules # testing coverage +**/.vitest-attachments/ +**/__screenshots__/ +**/*/package-lock.json +**/*/yalc.lock +.yalc* # next.js .next/ out/ build +next-env.d.ts dist .vercel @@ -33,5 +39,13 @@ yarn-error.log* .env.test.local .env.production.local -# turbo .turbo +.worktrees +.serena/ + +# cursor +.cursor/hooks/state/ + +# mintlify exports (generated locally) +apps/docs/export.zip +apps/docs/export/ diff --git a/.npmrc b/.npmrc deleted file mode 100644 index ded82e2f63..0000000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -auto-install-peers = true diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..7b46e2848f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports.biome": "explicit" + } +} diff --git a/README.md b/README.md index a68467d544..bf12000c07 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,9 @@
The next generation of writing emails.
High-quality, unstyled components for creating emails.

-Website +Website · -GitHub - · -Discord +GitHub
## Introduction @@ -18,38 +16,22 @@ It reduces the pain of coding responsive emails with dark mode support. It also ## Why -We believe that email is an extremely important medium for people to communicate. However, we need to stop developing emails like 2010, and rethink how email can be done in 2022 and beyond. Email development needs a revamp. A renovation. Modernized for the way we build web apps today. +We believe that email is an extremely important medium for people to communicate. However, we need to stop developing emails like 2010, and rethink how email can be done in 2026 and beyond. Email development needs a revamp. A renovation. Modernized for the way we build web apps today. ## Install -Install one of the components from your command line. - -#### With yarn - -```sh -yarn add @react-email/components -E -``` - -#### With npm - -```sh -npm install @react-email/components -E -``` - -#### With pnpm - ```sh -pnpm install @react-email/components -E +npm i react-email@latest ``` ## Getting started -Add the component to your email template. Include styles where needed. +Define your email template with React, include styles and our components where needed. ```jsx -import { Button } from "@react-email/components"; +import { Button } from "react-email"; -const Email = () => { +export default function Email() { return ( + + + + If you didn't request this, +
+ please ignore this email. +
+ + + {/* Footer */} +
+ + + + Barebones is the catchy slogan that perfectly encapsulates + the vision of our company. + + +
+ + X + + + LinkedIn + + + YouTube + + + GitHub + +
+ + + 123 Market Street, Floor 1 +
+ Tech City, CA, 94102 +
+ + + Unsubscribe + {' '} + from {companyName} marketing emails. + +
+
+
+ + + + + + +); + +ConfirmEmail.PreviewProps = { + companyName: 'Barebones', + url: 'https://example.com/', +} satisfies ConfirmEmailProps; + +export default ConfirmEmail; diff --git a/apps/demo/emails/01-Barebone/feature-announcement.tsx b/apps/demo/emails/01-Barebone/feature-announcement.tsx new file mode 100644 index 0000000000..b8c586952e --- /dev/null +++ b/apps/demo/emails/01-Barebone/feature-announcement.tsx @@ -0,0 +1,306 @@ +// Get the full source code, including the theme and Tailwind config: +// https://github.com/resend/react-email/tree/canary/apps/demo/emails + +import { + Body, + Button, + Column, + Container, + Head, + Heading, + Html, + Img, + Link, + Preview, + Row, + Section, + Tailwind, + Text, +} from 'react-email'; +import { barebonesBoxedTailwindConfig } from './theme'; +import { BarebonesFonts } from './theme-fonts'; + +const baseUrl = process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : ''; + +interface FeatureAnnouncementEmailProps { + companyName: string; + url: string; +} + +export const FeatureAnnouncementEmail = ({ + companyName, + url, +}: FeatureAnnouncementEmailProps) => ( + + + + + + + + Release notes — {companyName} + +
+
+
+ + + + + + + + + + + {companyName} + + + +
+ +
+
+ + What's new from {companyName} + + + Release Notes + + + Learn what's shipping this month, plus other{' '} + {companyName} updates below. + +
+ +
+ +
+ +
+ +
+ + New ways to work + + + + + + + + Automations that save real time + + + Bring your workflows into one place, cut manual handoffs, + and give everyone the same source of truth. + + + Read more + + + + + + + + + + A clearer view of what needs attention + + + Bring your workflows into one place, cut manual handoffs, + and give everyone the same source of truth. + + + Read more + + + +
+ +
+ +
+ +
+
+
+ +
+
+ + Start using {companyName} +
+ The fastest, easiest way to use {companyName}. +
+
+ +
+
+ + {/* Footer */} +
+ + + + {companyName} is the catchy slogan that perfectly + encapsulates the vision of our company. + + +
+ + X + + + LinkedIn + + + YouTube + + + GitHub + +
+ + + 123 Market Street, Floor 1 +
+ Tech City, CA, 94102 +
+ + + Unsubscribe + {' '} + from {companyName} marketing emails. + +
+
+
+
+
+
+ + +
+); + +function FeatureBlock({ + imageUrl, + ctaUrl, + title, + bodyP1, +}: { + imageUrl: string; + ctaUrl: string; + title: string; + bodyP1: string; +}) { + return ( +
+ +
+ {title} + {bodyP1} + +
+
+ ); +} + +FeatureAnnouncementEmail.PreviewProps = { + companyName: 'Barebones', + url: 'https://example.com/', +} satisfies FeatureAnnouncementEmailProps; + +export default FeatureAnnouncementEmail; diff --git a/apps/demo/emails/01-Barebone/password-reset.tsx b/apps/demo/emails/01-Barebone/password-reset.tsx new file mode 100644 index 0000000000..27606f66a3 --- /dev/null +++ b/apps/demo/emails/01-Barebone/password-reset.tsx @@ -0,0 +1,186 @@ +// Get the full source code, including the theme and Tailwind config: +// https://github.com/resend/react-email/tree/canary/apps/demo/emails + +import { + Body, + Button, + Column, + Container, + Head, + Heading, + Html, + Img, + Link, + Preview, + Row, + Section, + Tailwind, + Text, +} from 'react-email'; +import { barebonesBoxedTailwindConfig } from './theme'; +import { BarebonesFonts } from './theme-fonts'; + +const baseUrl = process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : ''; + +interface PasswordResetEmailProps { + companyName: string; + url: string; +} + +export const PasswordResetEmail = ({ + companyName, + url, +}: PasswordResetEmailProps) => ( + + + + + + + + Reset your password + +
+
+
+ + + + + + + + + + + {companyName} + + + +
+ +
+
+ Logo + + Reset your password + +
+ + + Someone has requested a link to change your password, and you + can do this through the link below. + + +
+ +
+ + + If you didn't request this, please ignore this email. + Your password won't change until you access the link + above and create a new one. + +
+ + {/* Footer */} +
+ + + + Barebones is the catchy slogan that perfectly encapsulates + the vision of our company. + + +
+ + X + + + LinkedIn + + + YouTube + + + GitHub + +
+ + + 123 Market Street, Floor 1 +
+ Tech City, CA, 94102 +
+ + + Unsubscribe + {' '} + from {companyName} marketing emails. + +
+
+
+
+
+
+ + +
+); + +PasswordResetEmail.PreviewProps = { + companyName: 'Barebones', + url: 'https://example.com/', +} satisfies PasswordResetEmailProps; + +export default PasswordResetEmail; diff --git a/apps/demo/emails/01-Barebone/product-update.tsx b/apps/demo/emails/01-Barebone/product-update.tsx new file mode 100644 index 0000000000..a3fb943316 --- /dev/null +++ b/apps/demo/emails/01-Barebone/product-update.tsx @@ -0,0 +1,468 @@ +// Get the full source code, including the theme and Tailwind config: +// https://github.com/resend/react-email/tree/canary/apps/demo/emails + +import { + Body, + Button, + Column, + Container, + Head, + Heading, + Html, + Img, + Link, + Preview, + Row, + Section, + Tailwind, + Text, +} from 'react-email'; +import { barebonesBoxedTailwindConfig } from './theme'; +import { BarebonesFonts } from './theme-fonts'; + +const baseUrl = process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : ''; + +interface ProductUpdateEmailProps { + companyName: string; + url: string; +} + +export const ProductUpdateEmail = ({ + companyName, + url, +}: ProductUpdateEmailProps) => ( + + + + + + + + Product update: what's new at {companyName} + +
+
+
+ + + + + + + + + + + {companyName} + + + +
+ +
+
+ + Product update + + + Here's what's new with {companyName} + + + We shipped a new release with improvements to help you move + faster and stay in sync. Open the dashboard to explore the + full changelog. + +
+ +
+
+ +
+ +
+
+ + Starting is easy + +
+ + + +
+
+ +
+
+
+ +
+ + Some new things + + + + + + Quality-of-life fixes + + + Expect faster load times, clearer status, and fewer clicks + for everyday tasks. We also tightened the spots where + teams tend to get stuck. + + + + + + Under the hood + + + Expect faster load times, clearer status, and fewer clicks + for everyday tasks. We also tightened the spots where + teams tend to get stuck. + + + +
+ +
+
+ +
+
+ + Some new things + + + + + + + + Workflow improvements + + + Built for teams who need reliability at scale: clearer + behavior, better defaults, and less back-and-forth to + get work done. + + + Learn about Pro + + + + + + + Reporting & visibility + + + Built for teams who need reliability at scale: clearer + behavior, better defaults, and less back-and-forth to + get work done. + + + + Learn about Pro + + + + + + + + + + + + + API & integrations + + + Built for teams who need reliability at scale: clearer + behavior, better defaults, and less back-and-forth to + get work done. + + + Learn about Pro + + + +
+
+ +
+
+ + Some new things + +
+ + + + + + + + +
+
+ +
+
+
+ +
+
+
+ +
+
+ + Get the app. +
+ The fastest, easiest way to use {companyName}. +
+
+ + + + + + + + +
+
+ + {/* Footer */} +
+ + + + {companyName} is the catchy slogan that perfectly + encapsulates the vision of our company. + + +
+ + X + + + LinkedIn + + + YouTube + + + GitHub + +
+ + + 123 Market Street, Floor 1 +
+ Tech City, CA, 94102 +
+ + + Unsubscribe + {' '} + from {companyName} marketing emails. + +
+
+
+
+
+
+ + +
+); + +function NumberedStep({ + n, + title, + body, + last, +}: { + n: string; + title: string; + body: string; + last?: boolean; +}) { + return ( + + +
+ + {n} + +
+
+ + + {title} + + + {body} + + +
+ ); +} + +function BulletCell({ isLast }: { isLast?: boolean }) { + return ( + + + +   + + + + These updates roll out gradually. Check your workspace to see + what's available to you today. + + + ); +} + +ProductUpdateEmail.PreviewProps = { + companyName: 'Barebones', + url: 'https://example.com/', +} satisfies ProductUpdateEmailProps; + +export default ProductUpdateEmail; diff --git a/apps/demo/emails/01-Barebone/subscription-confirmation.tsx b/apps/demo/emails/01-Barebone/subscription-confirmation.tsx new file mode 100644 index 0000000000..134813d39c --- /dev/null +++ b/apps/demo/emails/01-Barebone/subscription-confirmation.tsx @@ -0,0 +1,218 @@ +// Get the full source code, including the theme and Tailwind config: +// https://github.com/resend/react-email/tree/canary/apps/demo/emails + +import { + Body, + Button, + Column, + Container, + Head, + Heading, + Html, + Img, + Link, + Preview, + Row, + Section, + Tailwind, + Text, +} from 'react-email'; +import { barebonesBoxedTailwindConfig } from './theme'; +import { BarebonesFonts } from './theme-fonts'; + +const baseUrl = process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : ''; + +interface SubscriptionConfirmationProps { + companyName: string; + url: string; + userName: string; + planName: string; + planPrice: string; + cycleLabel: string; + nextBillingDate: string; +} + +export const SubscriptionConfirmation = ({ + companyName, + url, + userName, + planName, + planPrice, + cycleLabel, + nextBillingDate, +}: SubscriptionConfirmationProps) => { + return ( + + + + + + + + + You're subscribed to {companyName} {planName} + + +
+
+
+ + + + + + + + + + + {companyName} + + + +
+ +
+
+ Logo + + You're subscribed + +
+ + + Hi {userName}, +
+
+ Thank you for subscribing to {companyName} {planName}. Your + subscription is active—you now have access to everything + included in your plan. +
+ + + You're billed {planPrice} per {cycleLabel}. Your next + charge is on {nextBillingDate}. You can update payment + details or cancel anytime from your account. + + +
+ +
+ + + Questions about billing or your plan? +
+ Reply to this email—we're happy to help. +
+
+ + {/* Footer */} +
+ + + + Barebones is the catchy slogan that perfectly + encapsulates the vision of our company. + + +
+ + X + + + LinkedIn + + + YouTube + + + GitHub + +
+ + + 123 Market Street, Floor 1 +
+ Tech City, CA, 94102 +
+ + + Unsubscribe + {' '} + from {companyName} marketing emails. + +
+
+
+
+
+
+ + +
+ ); +}; + +SubscriptionConfirmation.PreviewProps = { + companyName: 'Barebones', + url: 'https://example.com/', + userName: 'Alex', + planName: 'Pro', + planPrice: '$29', + cycleLabel: 'month', + nextBillingDate: 'April 22, 2026', +} satisfies SubscriptionConfirmationProps; + +export default SubscriptionConfirmation; diff --git a/apps/demo/emails/01-Barebone/subscription-update.tsx b/apps/demo/emails/01-Barebone/subscription-update.tsx new file mode 100644 index 0000000000..850f9b56f2 --- /dev/null +++ b/apps/demo/emails/01-Barebone/subscription-update.tsx @@ -0,0 +1,218 @@ +// Get the full source code, including the theme and Tailwind config: +// https://github.com/resend/react-email/tree/canary/apps/demo/emails + +import { + Body, + Button, + Column, + Container, + Head, + Heading, + Html, + Img, + Link, + Preview, + Row, + Section, + Tailwind, + Text, +} from 'react-email'; +import { barebonesBoxedTailwindConfig } from './theme'; +import { BarebonesFonts } from './theme-fonts'; + +const baseUrl = process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : ''; + +interface SubscriptionUpdateProps { + companyName: string; + url: string; + userName: string; + planName: string; + planPrice: string; + cycleLabel: string; + nextBillingDate: string; +} + +export const SubscriptionUpdate = ({ + companyName, + url, + userName, + planName, + planPrice, + cycleLabel, + nextBillingDate, +}: SubscriptionUpdateProps) => { + return ( + + + + + + + + + Your {companyName} {planName} subscription was updated + + +
+
+
+ + + + + + + + + + + {companyName} + + + +
+ +
+
+ Logo + + Your plan was updated + +
+ + + Hi {userName}, +
+
+ Your {companyName} subscription has been updated. + Here's a summary of your current plan and billing. +
+ + + You're on {companyName} {planName} at {planPrice} per{' '} + {cycleLabel}. Your next charge is on {nextBillingDate}. You + can review invoices, update payment details, or change your + plan from your account settings. + + +
+ +
+ + + Something look off? +
+ Reply to this email and we'll help sort it out. +
+
+ + {/* Footer */} +
+ + + + Barebones is the catchy slogan that perfectly + encapsulates the vision of our company. + + +
+ + X + + + LinkedIn + + + YouTube + + + GitHub + +
+ + + 123 Market Street, Floor 1 +
+ Tech City, CA, 94102 +
+ + + Unsubscribe + {' '} + from {companyName} marketing emails. + +
+
+
+
+
+
+ + +
+ ); +}; + +SubscriptionUpdate.PreviewProps = { + companyName: 'Barebones', + url: 'https://example.com/', + userName: 'Alex', + planName: 'Pro', + planPrice: '$29', + cycleLabel: 'month', + nextBillingDate: 'April 22, 2026', +} satisfies SubscriptionUpdateProps; + +export default SubscriptionUpdate; diff --git a/apps/demo/emails/01-Barebone/text-only.tsx b/apps/demo/emails/01-Barebone/text-only.tsx new file mode 100644 index 0000000000..5b397c15a7 --- /dev/null +++ b/apps/demo/emails/01-Barebone/text-only.tsx @@ -0,0 +1,188 @@ +// Get the full source code, including the theme and Tailwind config: +// https://github.com/resend/react-email/tree/canary/apps/demo/emails + +import { + Body, + Column, + Container, + Head, + Heading, + Html, + Img, + Link, + Preview, + Row, + Section, + Tailwind, + Text, +} from 'react-email'; +import { barebonesBoxedTailwindConfig } from './theme'; +import { BarebonesFonts } from './theme-fonts'; + +const baseUrl = process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : ''; + +const textOnlyTitle = 'A quick note from Barebones'; + +const textOnlyParagraphs = [ + 'This is the text-only layout: no hero image and no primary call-to-action. Use it for concise product notes, receipts, account updates, and other plain-language emails where the message should stay front and center.', + 'Keep paragraphs short and focused so the content is easy to scan on mobile. If you need richer visuals or a conversion button, switch to another collage template.', + 'Replace this placeholder copy by editing this template file directly.', +]; + +interface TextOnlyEmailProps { + companyName: string; + url: string; +} + +export const TextOnlyEmail = ({ companyName, url }: TextOnlyEmailProps) => ( + + + + + + + + A note from {companyName} + +
+
+
+ + + + + + + + + + + {companyName} + + + +
+ +
+
+ + {textOnlyTitle} + +
+ + {textOnlyParagraphs.map((block, i) => ( + + {block} + + ))} + + + + Open your account + + + + + Thanks, +
+ The Barebones Team +
+
+ + {/* Footer */} +
+ + + + Barebones is the catchy slogan that perfectly encapsulates + the vision of our company. + + +
+ + X + + + LinkedIn + + + YouTube + + + GitHub + +
+ + + 123 Market Street, Floor 1 +
+ Tech City, CA, 94102 +
+ + + Unsubscribe + {' '} + from {companyName} marketing emails. + +
+
+
+
+
+
+ + +
+); + +TextOnlyEmail.PreviewProps = { + companyName: 'Barebones', + url: 'https://example.com/', +} satisfies TextOnlyEmailProps; + +export default TextOnlyEmail; diff --git a/apps/demo/emails/01-Barebone/theme-fonts.tsx b/apps/demo/emails/01-Barebone/theme-fonts.tsx new file mode 100644 index 0000000000..9f01fc11ab --- /dev/null +++ b/apps/demo/emails/01-Barebone/theme-fonts.tsx @@ -0,0 +1,48 @@ +import { Font } from 'react-email'; + +/** + * Inter variable family (weights 100–900) via Google CSS `@import`. + * Many webmail clients strip `@import`; the `` entries below register 400 / 500 / 600 + * static files as a fallback when the import does not run. + */ +export function BarebonesFonts() { + return ( + <> + } + + {previewText && previewText !== '' && ( + {previewText} + )} + + {children} + + ); + }, + } satisfies SerializerPlugin, + }; + }, + + addProseMirrorPlugins() { + const { editor } = this; + const scopeId = `tiptap-theme-${Math.random().toString(36).slice(2, 10)}`; + const scopeAttribute = 'data-editor-theme-scope'; + const scopeSelector = `.tiptap.ProseMirror[${scopeAttribute}="${scopeId}"]`; + const themeStyleId = `${scopeId}-theme`; + const globalStyleId = `${scopeId}-global`; + + return [ + new Plugin({ + key: new PluginKey('themingStyleInjector'), + view(view) { + let prevStyles: PanelGroup[] | null = null; + let prevTheme: EditorTheme | null = null; + let prevCss: string | null = null; + let seededFromConfig = false; + + view.dom.setAttribute(scopeAttribute, scopeId); + + const sync = () => { + if (!seededFromConfig) { + seededFromConfig = true; + const extensionTheme = ( + editor.extensionManager.extensions.find( + (ext) => ext.name === 'theming', + ) as { options?: { theme?: EditorThemeInput } } + )?.options?.theme; + + if (isThemeConfig(extensionTheme)) { + const { baseTheme, panels } = + resolveThemeConfig(extensionTheme); + if (panels && !getGlobalContent('styles', editor)) { + editor.commands.setGlobalContent('styles', panels); + } + if (!getGlobalContent('theme', editor)) { + editor.commands.setGlobalContent('theme', baseTheme); + } + } + } + + const theme = getEmailTheme(editor); + const styles = getEmailStyles(editor); + const resolvedStyles = styles ?? EDITOR_THEMES[theme]; + const css = getEmailCss(editor); + + if (styles !== prevStyles || theme !== prevTheme) { + prevStyles = styles as PanelGroup[] | null; + prevTheme = theme; + const mergedCssJs = getMergedCssJs(theme, resolvedStyles); + injectThemeCss(mergedCssJs, { + scopeSelector, + styleId: themeStyleId, + }); + } + + if (css !== prevCss) { + prevCss = css; + injectGlobalPlainCss(css, { + scopeSelector, + styleId: globalStyleId, + }); + } + }; + + sync(); + + return { + update: sync, + destroy() { + document.getElementById(themeStyleId)?.remove(); + document.getElementById(globalStyleId)?.remove(); + view.dom.removeAttribute(scopeAttribute); + }, + }; + }, + }), + ]; + }, +}); diff --git a/packages/editor/src/plugins/email-theming/index.ts b/packages/editor/src/plugins/email-theming/index.ts new file mode 100644 index 0000000000..45ee593798 --- /dev/null +++ b/packages/editor/src/plugins/email-theming/index.ts @@ -0,0 +1,4 @@ +export * from './extension'; +export * from './theme-config'; +export * from './themes'; +export * from './types'; diff --git a/packages/editor/src/plugins/email-theming/normalization.spec.ts b/packages/editor/src/plugins/email-theming/normalization.spec.ts new file mode 100644 index 0000000000..fdee49ddeb --- /dev/null +++ b/packages/editor/src/plugins/email-theming/normalization.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { + inferThemeFromPanelStyles, + normalizeThemePanelStyles, +} from './normalization'; + +describe('normalizeThemePanelStyles', () => { + it('maps legacy global panel groups to the current section ids', () => { + const result = normalizeThemePanelStyles('minimal', [ + { + title: 'Body', + classReference: 'body', + inputs: [], + }, + { + title: 'Container', + classReference: 'container', + inputs: [], + }, + { + title: 'Typography', + classReference: 'body', + inputs: [], + }, + { + title: 'Code Block', + classReference: 'codeBlock', + inputs: [], + }, + ]); + + expect(result).toEqual([ + expect.objectContaining({ + id: 'body', + title: 'Background', + classReference: 'body', + }), + expect.objectContaining({ + id: 'container', + title: 'Content', + classReference: 'container', + }), + expect.objectContaining({ + id: 'typography', + title: 'Text', + classReference: 'body', + }), + expect.objectContaining({ + id: 'code-block', + title: 'Code Block', + classReference: 'codeBlock', + }), + ]); + }); +}); + +describe('inferThemeFromPanelStyles', () => { + it('infers the minimal theme from legacy empty panel groups', () => { + expect( + inferThemeFromPanelStyles([ + { title: 'Body', classReference: 'body', inputs: [] }, + { title: 'Container', classReference: 'container', inputs: [] }, + ]), + ).toBe('minimal'); + }); +}); diff --git a/packages/editor/src/plugins/email-theming/normalization.ts b/packages/editor/src/plugins/email-theming/normalization.ts new file mode 100644 index 0000000000..4ce4c2aea1 --- /dev/null +++ b/packages/editor/src/plugins/email-theming/normalization.ts @@ -0,0 +1,176 @@ +import { EDITOR_THEMES } from './themes'; +import type { + EditorTheme, + KnownThemeComponents, + PanelGroup, + PanelSectionId, +} from './types'; + +const PANEL_SECTION_IDS = new Set([ + 'body', + 'container', + 'typography', + 'paragraph', + 'list', + 'nested-list', + 'list-item', + 'link', + 'image', + 'button', + 'code-block', + 'inline-code', +]); + +const PANEL_SECTION_IDS_BY_TITLE: Record = { + background: 'body', + body: 'body', + content: 'container', + container: 'container', + typography: 'typography', + paragraph: 'paragraph', + list: 'list', + 'nested list': 'nested-list', + 'list item': 'list-item', + link: 'link', + image: 'image', + button: 'button', + 'code block': 'code-block', + 'inline code': 'inline-code', +}; + +const PANEL_SECTION_IDS_BY_CLASS_REFERENCE: Partial< + Record +> = { + container: 'container', + paragraph: 'paragraph', + list: 'list', + nestedList: 'nested-list', + listItem: 'list-item', + link: 'link', + image: 'image', + button: 'button', + codeBlock: 'code-block', + inlineCode: 'inline-code', +}; + +function isPanelSectionId(value: unknown): value is PanelSectionId { + return ( + typeof value === 'string' && PANEL_SECTION_IDS.has(value as PanelSectionId) + ); +} + +function normalizeTitle(title: string | undefined): string { + return title?.trim().toLowerCase().replace(/\s+/g, ' ') ?? ''; +} + +function resolvePanelSectionId(group: PanelGroup): PanelSectionId | null { + if (isPanelSectionId(group.id)) { + return group.id; + } + + const normalizedTitle = normalizeTitle(group.title); + + if (group.classReference === 'body') { + if (normalizedTitle === 'typography') { + return 'typography'; + } + + return 'body'; + } + + if ( + group.classReference && + PANEL_SECTION_IDS_BY_CLASS_REFERENCE[group.classReference] + ) { + return PANEL_SECTION_IDS_BY_CLASS_REFERENCE[group.classReference] ?? null; + } + + return PANEL_SECTION_IDS_BY_TITLE[normalizedTitle] ?? null; +} + +function normalizePanelInputs( + inputs: PanelGroup['inputs'], + defaultInputs: PanelGroup['inputs'], + fallbackClassReference?: KnownThemeComponents, +): PanelGroup['inputs'] { + if (!Array.isArray(inputs)) { + return []; + } + + return inputs.map((input) => { + const defaultInput = defaultInputs.find( + (candidate) => candidate.prop === input.prop, + ); + + return { + ...defaultInput, + ...input, + classReference: + input.classReference ?? + defaultInput?.classReference ?? + fallbackClassReference, + }; + }); +} + +export function inferThemeFromPanelStyles( + panelStyles: PanelGroup[] | null | undefined, +): EditorTheme | null { + if (!Array.isArray(panelStyles) || panelStyles.length === 0) { + return null; + } + + let finalTheme: EditorTheme | null = null; + for (const group of panelStyles) { + if (!Array.isArray(group?.inputs)) { + finalTheme = null; + break; + } + + if (group.inputs.length !== 0) { + finalTheme = 'basic'; + break; + } + + finalTheme = 'minimal'; + } + + return finalTheme; +} + +export function normalizeThemePanelStyles( + theme: EditorTheme, + panelStyles: PanelGroup[] | null | undefined, +): PanelGroup[] | null { + if (!Array.isArray(panelStyles)) { + return null; + } + + return panelStyles.map((group) => { + const panelId = resolvePanelSectionId(group); + + if (!panelId) { + return group; + } + + const defaultGroup = EDITOR_THEMES[theme].find( + (candidate) => candidate.id === panelId, + ); + + if (!defaultGroup) { + return group; + } + + return { + ...group, + id: panelId, + title: defaultGroup.title, + classReference: defaultGroup.classReference, + inputs: normalizePanelInputs( + group.inputs, + defaultGroup.inputs, + defaultGroup.classReference, + ), + }; + }); +} diff --git a/packages/editor/src/plugins/email-theming/theme-config.spec.ts b/packages/editor/src/plugins/email-theming/theme-config.spec.ts new file mode 100644 index 0000000000..3dbf584ad5 --- /dev/null +++ b/packages/editor/src/plugins/email-theming/theme-config.spec.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'vitest'; +import { + createTheme, + extendTheme, + isThemeConfig, + parseCssValue, + themeStylesToPanelOverrides, +} from './theme-config'; +import { EDITOR_THEMES } from './themes'; + +describe('parseCssValue', () => { + it('parses pixel strings', () => { + expect(parseCssValue('32px')).toEqual({ value: 32, unit: 'px' }); + }); + + it('parses percentage strings', () => { + expect(parseCssValue('155%')).toEqual({ value: 155, unit: '%' }); + }); + + it('passes plain numbers through without unit', () => { + expect(parseCssValue(600)).toEqual({ value: 600 }); + }); + + it('parses color strings without unit', () => { + expect(parseCssValue('#0670DB')).toEqual({ value: '#0670DB' }); + }); + + it('parses keyword strings without unit', () => { + expect(parseCssValue('underline')).toEqual({ value: 'underline' }); + }); + + it('parses zero as px string', () => { + expect(parseCssValue('0px')).toEqual({ value: 0, unit: 'px' }); + }); + + it('parses decimal pixel values', () => { + expect(parseCssValue('1.5px')).toEqual({ value: 1.5, unit: 'px' }); + }); + + it('parses decimal percentage values', () => { + expect(parseCssValue('12.5%')).toEqual({ value: 12.5, unit: '%' }); + }); +}); + +describe('isThemeConfig', () => { + it('returns false for string theme "basic"', () => { + expect(isThemeConfig('basic')).toBe(false); + }); + + it('returns false for string theme "minimal"', () => { + expect(isThemeConfig('minimal')).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isThemeConfig(undefined)).toBe(false); + }); + + it('returns true for object with styles property', () => { + expect(isThemeConfig({ styles: {} })).toBe(true); + }); + + it('returns true for object with extends and styles', () => { + expect( + isThemeConfig({ + extends: 'basic', + styles: { body: { backgroundColor: '#ff0000' } }, + }), + ).toBe(true); + }); +}); + +describe('themeStylesToPanelOverrides', () => { + it('overrides body background on basic theme', () => { + const base = EDITOR_THEMES.basic; + const result = themeStylesToPanelOverrides( + { body: { backgroundColor: '#ff0000' } }, + base, + ); + const bodyGroup = result.find((g) => g.id === 'body'); + const bgInput = bodyGroup?.inputs.find( + (i) => i.prop === 'backgroundColor' && i.classReference === 'body', + ); + expect(bgInput?.value).toBe('#ff0000'); + }); + + it('overrides button backgroundColor and borderRadius as px string', () => { + const base = EDITOR_THEMES.basic; + const result = themeStylesToPanelOverrides( + { button: { backgroundColor: '#123456', borderRadius: '8px' } }, + base, + ); + const buttonGroup = result.find((g) => g.id === 'button'); + const bgInput = buttonGroup?.inputs.find( + (i) => i.prop === 'backgroundColor' && i.classReference === 'button', + ); + const radiusInput = buttonGroup?.inputs.find( + (i) => i.prop === 'borderRadius' && i.classReference === 'button', + ); + expect(bgInput?.value).toBe('#123456'); + expect(radiusInput?.value).toBe(8); + expect(radiusInput?.unit).toBe('px'); + }); + + it('overrides link color', () => { + const base = EDITOR_THEMES.basic; + const result = themeStylesToPanelOverrides( + { link: { color: '#abcdef' } }, + base, + ); + const linkGroup = result.find((g) => g.id === 'link'); + const colorInput = linkGroup?.inputs.find( + (i) => i.prop === 'color' && i.classReference === 'link', + ); + expect(colorInput?.value).toBe('#abcdef'); + }); + + it('preserves unmodified container width of 600', () => { + const base = EDITOR_THEMES.basic; + const result = themeStylesToPanelOverrides( + { body: { backgroundColor: '#ff0000' } }, + base, + ); + const containerGroup = result.find((g) => g.id === 'container'); + const widthInput = containerGroup?.inputs.find( + (i) => i.prop === 'width' && i.classReference === 'container', + ); + expect(widthInput?.value).toBe(600); + }); + + it('works with minimal theme base', () => { + const base = EDITOR_THEMES.minimal; + const result = themeStylesToPanelOverrides( + { link: { color: '#ff0000' } }, + base, + ); + const linkGroup = result.find((g) => g.id === 'link'); + const colorInput = linkGroup?.inputs.find( + (i) => i.prop === 'color' && i.classReference === 'link', + ); + expect(colorInput?.value).toBe('#ff0000'); + }); + + it('adds new input when property does not exist in base panel (h1 color)', () => { + const base = EDITOR_THEMES.basic; + const result = themeStylesToPanelOverrides( + { h1: { color: '#ff0000' } }, + base, + ); + const h1Group = result.find((g) => g.id === 'h1'); + const colorInput = h1Group?.inputs.find( + (i) => i.prop === 'color' && i.classReference === 'h1', + ); + expect(colorInput?.value).toBe('#ff0000'); + }); + + it('adds new input with parsed unit when property does not exist (h1 fontSize as "32px")', () => { + const base = EDITOR_THEMES.basic; + const result = themeStylesToPanelOverrides( + { h1: { fontSize: '32px' } }, + base, + ); + const h1Group = result.find((g) => g.id === 'h1'); + const fontSizeInput = h1Group?.inputs.find( + (i) => i.prop === 'fontSize' && i.classReference === 'h1', + ); + expect(fontSizeInput?.value).toBe(32); + expect(fontSizeInput?.unit).toBe('px'); + }); + + it('adds list theme overrides to list panels', () => { + const base = EDITOR_THEMES.basic; + const result = themeStylesToPanelOverrides( + { + list: { paddingLeft: '24px' }, + nestedList: { paddingBottom: '0px' }, + listItem: { paddingTop: '6px' }, + }, + base, + ); + const listInput = result + .find((g) => g.id === 'list') + ?.inputs.find( + (i) => i.prop === 'paddingLeft' && i.classReference === 'list', + ); + const nestedListInput = result + .find((g) => g.id === 'nested-list') + ?.inputs.find( + (i) => i.prop === 'paddingBottom' && i.classReference === 'nestedList', + ); + const listItemInput = result + .find((g) => g.id === 'list-item') + ?.inputs.find( + (i) => i.prop === 'paddingTop' && i.classReference === 'listItem', + ); + + expect(listInput?.value).toBe(24); + expect(listInput?.unit).toBe('px'); + expect(nestedListInput?.value).toBe(0); + expect(nestedListInput?.unit).toBe('px'); + expect(listItemInput?.value).toBe(6); + expect(listItemInput?.unit).toBe('px'); + }); + + it('does not mutate base panels', () => { + const base = EDITOR_THEMES.basic; + const originalBodyBg = base + .find((g) => g.id === 'body') + ?.inputs.find((i) => i.prop === 'backgroundColor')?.value; + themeStylesToPanelOverrides( + { body: { backgroundColor: '#mutated' } }, + base, + ); + const afterBodyBg = base + .find((g) => g.id === 'body') + ?.inputs.find((i) => i.prop === 'backgroundColor')?.value; + expect(afterBodyBg).toBe(originalBodyBg); + }); +}); + +describe('createTheme', () => { + it('returns ThemeConfig without extends', () => { + const result = createTheme({ body: { backgroundColor: '#ff0000' } }); + expect(result).toEqual({ + styles: { body: { backgroundColor: '#ff0000' } }, + }); + expect(result.extends).toBeUndefined(); + }); +}); + +describe('extendTheme', () => { + it('returns ThemeConfig with extends set', () => { + const result = extendTheme('basic', { + body: { backgroundColor: '#ff0000' }, + }); + expect(result).toEqual({ + extends: 'basic', + styles: { body: { backgroundColor: '#ff0000' } }, + }); + }); + + it('works with minimal base', () => { + const result = extendTheme('minimal', { link: { color: '#abc' } }); + expect(result.extends).toBe('minimal'); + expect(result.styles).toEqual({ link: { color: '#abc' } }); + }); +}); diff --git a/packages/editor/src/plugins/email-theming/theme-config.ts b/packages/editor/src/plugins/email-theming/theme-config.ts new file mode 100644 index 0000000000..7ff0e088d4 --- /dev/null +++ b/packages/editor/src/plugins/email-theming/theme-config.ts @@ -0,0 +1,118 @@ +import { SUPPORTED_CSS_PROPERTIES } from './themes'; +import type { + EditorTheme, + EditorThemeInput, + KnownCssProperties, + PanelGroup, + PanelSectionId, + ThemeableComponent, + ThemeComponentStyles, + ThemeConfig, +} from './types'; + +const CLASS_REFERENCE_TO_PANEL_ID: Record = + { + body: 'body', + container: 'container', + h1: 'h1', + h2: 'h2', + h3: 'h3', + paragraph: 'paragraph', + link: 'link', + image: 'image', + button: 'button', + list: 'list', + nestedList: 'nested-list', + listItem: 'list-item', + codeBlock: 'code-block', + inlineCode: 'inline-code', + }; + +export function parseCssValue(value: string | number): { + value: string | number; + unit?: 'px' | '%'; +} { + if (typeof value === 'number') { + return { value }; + } + const pxMatch = /^(-?\d+(?:\.\d+)?)px$/.exec(value); + if (pxMatch) { + return { value: Number.parseFloat(pxMatch[1]), unit: 'px' }; + } + const percentMatch = /^(-?\d+(?:\.\d+)?)%$/.exec(value); + if (percentMatch) { + return { value: Number.parseFloat(percentMatch[1]), unit: '%' }; + } + return { value }; +} + +export function isThemeConfig( + theme: EditorThemeInput | undefined, +): theme is ThemeConfig { + return typeof theme === 'object' && theme !== null && 'styles' in theme; +} + +export function themeStylesToPanelOverrides( + styles: ThemeComponentStyles, + basePanels: PanelGroup[], +): PanelGroup[] { + const result: PanelGroup[] = basePanels.map((group) => ({ + ...group, + inputs: group.inputs.map((input) => ({ ...input })), + })); + + for (const [component, cssProps] of Object.entries(styles) as [ + ThemeableComponent, + React.CSSProperties, + ][]) { + if (!cssProps) continue; + const panelId = CLASS_REFERENCE_TO_PANEL_ID[component]; + + const group = result.find((g) => g.id === panelId); + if (!group) continue; + + for (const [cssProp, cssValue] of Object.entries(cssProps) as [ + KnownCssProperties, + string | number, + ][]) { + if (cssValue === undefined) continue; + + const existingInput = group.inputs.find( + (i) => i.prop === cssProp && i.classReference === component, + ); + + const parsed = parseCssValue(cssValue); + + if (existingInput) { + existingInput.value = parsed.value; + if (parsed.unit !== undefined) { + existingInput.unit = parsed.unit; + } + } else { + const propMeta = SUPPORTED_CSS_PROPERTIES[cssProp]; + group.inputs.push({ + label: propMeta?.label ?? cssProp, + type: propMeta?.type ?? 'text', + prop: cssProp, + classReference: component, + value: parsed.value, + unit: parsed.unit ?? propMeta?.unit, + options: propMeta?.options, + }); + } + } + } + + return result; +} + +export function createTheme(styles: ThemeComponentStyles): ThemeConfig { + return { styles }; +} + +export function extendTheme( + base: EditorTheme, + overrides: ThemeComponentStyles, +): ThemeConfig { + return { extends: base, styles: overrides }; +} diff --git a/packages/editor/src/plugins/email-theming/themes.ts b/packages/editor/src/plugins/email-theming/themes.ts new file mode 100644 index 0000000000..bcc9e7923c --- /dev/null +++ b/packages/editor/src/plugins/email-theming/themes.ts @@ -0,0 +1,1149 @@ +import type { + EditorTheme, + PanelGroup, + PanelSectionId, + ResetTheme, + SupportedCssProperties, +} from './types'; + +/** + * Single source of truth for panel section display titles. + * Titles are resolved from here at render time via `getPanelTitle`, + * so they never depend on what's persisted in the DB. + */ +const PANEL_SECTION_TITLES: Record = { + body: 'Background', + container: 'Body', + typography: 'Text', + h1: 'Title', + h2: 'Subtitle', + h3: 'Heading', + paragraph: 'Paragraph', + list: 'List', + 'nested-list': 'Nested List', + 'list-item': 'List Item', + link: 'Link', + image: 'Image', + button: 'Button', + 'code-block': 'Code Block', + 'inline-code': 'Inline Code', +}; + +/** + * Resolves the display title for a panel group. + * Uses the `id` lookup when available, falls back to the + * DB-persisted `title` for backwards compatibility. + */ +export function getPanelTitle(group: PanelGroup): string { + if (group.id && group.id in PANEL_SECTION_TITLES) { + return PANEL_SECTION_TITLES[group.id]; + } + return group.title; +} + +const THEME_BASIC: PanelGroup[] = [ + { + id: 'body', + title: 'Background', + classReference: 'body', + inputs: [ + { + label: 'Background', + type: 'color', + value: '#ffffff', + prop: 'backgroundColor', + classReference: 'body', + }, + { + label: 'Padding Top', + type: 'number', + value: undefined, + unit: 'px', + prop: 'paddingTop', + classReference: 'body', + }, + { + label: 'Padding Right', + type: 'number', + value: undefined, + unit: 'px', + prop: 'paddingRight', + classReference: 'body', + }, + { + label: 'Padding Bottom', + type: 'number', + value: undefined, + unit: 'px', + prop: 'paddingBottom', + classReference: 'body', + }, + { + label: 'Padding Left', + type: 'number', + value: undefined, + unit: 'px', + prop: 'paddingLeft', + classReference: 'body', + }, + ], + }, + { + id: 'container', + title: 'Content', + classReference: 'container', + inputs: [ + { + label: 'Align', + type: 'select', + value: 'left', + options: { + left: 'Left', + center: 'Center', + right: 'Right', + }, + prop: 'align', + classReference: 'container', + }, + { + label: 'Width', + type: 'number', + value: 600, + unit: 'px', + prop: 'width', + classReference: 'container', + }, + { + label: 'Height', + type: 'number', + unit: 'px', + prop: 'height', + classReference: 'container', + }, + { + label: 'Text', + type: 'color', + value: '#000000', + prop: 'color', + classReference: 'container', + }, + { + label: 'Background', + type: 'color', + value: '#ffffff', + prop: 'backgroundColor', + classReference: 'container', + }, + { + label: 'Padding Top', + type: 'number', + value: 0, + unit: 'px', + prop: 'paddingTop', + classReference: 'container', + }, + { + label: 'Padding Right', + type: 'number', + value: 0, + unit: 'px', + prop: 'paddingRight', + classReference: 'container', + }, + { + label: 'Padding Bottom', + type: 'number', + value: 0, + unit: 'px', + prop: 'paddingBottom', + classReference: 'container', + }, + { + label: 'Padding Left', + type: 'number', + value: 0, + unit: 'px', + prop: 'paddingLeft', + classReference: 'container', + }, + { + label: 'Corner radius', + type: 'number', + value: 0, + unit: 'px', + prop: 'borderRadius', + classReference: 'container', + }, + { + label: 'Border color', + type: 'color', + value: '#000000', + prop: 'borderColor', + classReference: 'container', + }, + ], + }, + { + id: 'typography', + title: 'Text', + classReference: 'body', + inputs: [ + { + label: 'Font size', + type: 'number', + value: 14, + unit: 'px', + prop: 'fontSize', + classReference: 'body', + }, + { + label: 'Line Height', + type: 'number', + value: 155, + unit: '%', + prop: 'lineHeight', + classReference: 'container', + }, + ], + }, + { + id: 'h1', + title: 'Title', + category: 'Text', + classReference: 'h1', + inputs: [], + }, + { + id: 'h2', + title: 'Subtitle', + category: 'Text', + classReference: 'h2', + inputs: [], + }, + { + id: 'h3', + title: 'Heading', + category: 'Text', + classReference: 'h3', + inputs: [], + }, + { + id: 'paragraph', + title: 'Paragraph', + category: 'Text', + classReference: 'paragraph', + inputs: [], + }, + { + id: 'list', + title: 'List', + category: 'Text', + classReference: 'list', + inputs: [], + }, + { + id: 'nested-list', + title: 'Nested List', + category: 'Text', + classReference: 'nestedList', + inputs: [], + }, + { + id: 'list-item', + title: 'List Item', + category: 'Text', + classReference: 'listItem', + inputs: [], + }, + { + id: 'link', + title: 'Link', + classReference: 'link', + inputs: [ + { + label: 'Color', + type: 'color', + value: '#0670DB', + prop: 'color', + classReference: 'link', + }, + { + label: 'Decoration', + type: 'select', + value: 'underline', + prop: 'textDecoration', + options: { + underline: 'Underline', + none: 'None', + }, + classReference: 'link', + }, + ], + }, + { + id: 'image', + title: 'Image', + classReference: 'image', + inputs: [ + { + label: 'Border radius', + type: 'number', + value: 8, + unit: 'px', + prop: 'borderRadius', + classReference: 'image', + }, + ], + }, + { + id: 'button', + title: 'Button', + classReference: 'button', + inputs: [ + { + label: 'Background', + type: 'color', + value: '#000000', + prop: 'backgroundColor', + classReference: 'button', + }, + { + label: 'Text color', + type: 'color', + value: '#ffffff', + prop: 'color', + classReference: 'button', + }, + { + label: 'Radius', + type: 'number', + value: 4, + unit: 'px', + prop: 'borderRadius', + classReference: 'button', + }, + { + label: 'Padding Top', + type: 'number', + value: 7, + unit: 'px', + prop: 'paddingTop', + classReference: 'button', + }, + { + label: 'Padding Right', + type: 'number', + value: 12, + unit: 'px', + prop: 'paddingRight', + classReference: 'button', + }, + { + label: 'Padding Bottom', + type: 'number', + value: 7, + unit: 'px', + prop: 'paddingBottom', + classReference: 'button', + }, + { + label: 'Padding Left', + type: 'number', + value: 12, + unit: 'px', + prop: 'paddingLeft', + classReference: 'button', + }, + ], + }, + { + id: 'code-block', + title: 'Code Block', + classReference: 'codeBlock', + inputs: [ + { + label: 'Border Radius', + type: 'number', + value: 4, + unit: 'px', + prop: 'borderRadius', + classReference: 'codeBlock', + }, + { + label: 'Padding Top', + type: 'number', + value: 12, + unit: 'px', + prop: 'paddingTop', + classReference: 'codeBlock', + }, + { + label: 'Padding Bottom', + type: 'number', + value: 12, + unit: 'px', + prop: 'paddingBottom', + classReference: 'codeBlock', + }, + { + label: 'Padding Left', + type: 'number', + value: 16, + unit: 'px', + prop: 'paddingLeft', + classReference: 'codeBlock', + }, + { + label: 'Padding Right', + type: 'number', + value: 16, + unit: 'px', + prop: 'paddingRight', + classReference: 'codeBlock', + }, + ], + }, + { + id: 'inline-code', + title: 'Inline Code', + classReference: 'inlineCode', + inputs: [ + { + label: 'Background', + type: 'color', + value: '#e5e7eb', + prop: 'backgroundColor', + classReference: 'inlineCode', + }, + { + label: 'Text color', + type: 'color', + value: '#1e293b', + prop: 'color', + classReference: 'inlineCode', + }, + { + label: 'Radius', + type: 'number', + value: 4, + unit: 'px', + prop: 'borderRadius', + classReference: 'inlineCode', + }, + ], + }, +]; + +const THEME_MINIMAL: PanelGroup[] = [ + { + id: 'body', + title: 'Background', + classReference: 'body', + inputs: [ + { + label: 'Background', + type: 'color', + value: '#ffffff', + prop: 'backgroundColor', + classReference: 'body', + }, + { + label: 'Padding Top', + type: 'number', + value: undefined, + unit: 'px', + prop: 'paddingTop', + classReference: 'body', + }, + { + label: 'Padding Right', + type: 'number', + value: undefined, + unit: 'px', + prop: 'paddingRight', + classReference: 'body', + }, + { + label: 'Padding Bottom', + type: 'number', + value: undefined, + unit: 'px', + prop: 'paddingBottom', + classReference: 'body', + }, + { + label: 'Padding Left', + type: 'number', + value: undefined, + unit: 'px', + prop: 'paddingLeft', + classReference: 'body', + }, + ], + }, + { + id: 'container', + title: 'Content', + classReference: 'container', + inputs: [ + { + label: 'Align', + type: 'select', + value: 'left', + options: { + left: 'Left', + center: 'Center', + right: 'Right', + }, + prop: 'align', + classReference: 'container', + }, + { + label: 'Width', + type: 'number', + value: 600, + unit: 'px', + prop: 'width', + classReference: 'container', + }, + { + label: 'Height', + type: 'number', + unit: 'px', + prop: 'height', + classReference: 'container', + }, + { + label: 'Text', + type: 'color', + value: '#000000', + prop: 'color', + classReference: 'container', + }, + { + label: 'Background', + type: 'color', + value: '#ffffff', + prop: 'backgroundColor', + classReference: 'container', + }, + { + label: 'Padding Top', + type: 'number', + value: 0, + unit: 'px', + prop: 'paddingTop', + classReference: 'container', + }, + { + label: 'Padding Right', + type: 'number', + value: 0, + unit: 'px', + prop: 'paddingRight', + classReference: 'container', + }, + { + label: 'Padding Bottom', + type: 'number', + value: 0, + unit: 'px', + prop: 'paddingBottom', + classReference: 'container', + }, + { + label: 'Padding Left', + type: 'number', + value: 0, + unit: 'px', + prop: 'paddingLeft', + classReference: 'container', + }, + { + label: 'Corner radius', + type: 'number', + value: 0, + unit: 'px', + prop: 'borderRadius', + classReference: 'container', + }, + { + label: 'Border color', + type: 'color', + value: '#000000', + prop: 'borderColor', + classReference: 'container', + }, + ], + }, + { + id: 'typography', + title: 'Text', + classReference: 'body', + inputs: [], + }, + { + id: 'h1', + title: 'Title', + category: 'Text', + classReference: 'h1', + inputs: [], + }, + { + id: 'h2', + title: 'Subtitle', + category: 'Text', + classReference: 'h2', + inputs: [], + }, + { + id: 'h3', + title: 'Heading', + category: 'Text', + classReference: 'h3', + inputs: [], + }, + { + id: 'paragraph', + title: 'Paragraph', + category: 'Text', + classReference: 'paragraph', + inputs: [], + }, + { + id: 'list', + title: 'List', + category: 'Text', + classReference: 'list', + inputs: [], + }, + { + id: 'nested-list', + title: 'Nested List', + category: 'Text', + classReference: 'nestedList', + inputs: [], + }, + { + id: 'list-item', + title: 'List Item', + category: 'Text', + classReference: 'listItem', + inputs: [], + }, + { + id: 'link', + title: 'Link', + classReference: 'link', + inputs: [], + }, + { + id: 'image', + title: 'Image', + classReference: 'image', + inputs: [], + }, + { + id: 'button', + title: 'Button', + classReference: 'button', + inputs: [], + }, + { + id: 'code-block', + title: 'Code Block', + classReference: 'codeBlock', + inputs: [], + }, + { + id: 'inline-code', + title: 'Inline Code', + classReference: 'inlineCode', + inputs: [], + }, +]; + +const RESET_BASIC: ResetTheme = { + reset: { + margin: '0', + padding: '0', + }, + body: { + fontFamily: + "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", + fontSize: '14px', + minHeight: '100%', + lineHeight: '155%', + }, + container: {}, + h1: { + fontSize: '2.25em', + lineHeight: '1.44em', + paddingTop: '0.389em', + fontWeight: 600, + }, + h2: { + fontSize: '1.8em', + lineHeight: '1.44em', + paddingTop: '0.389em', + fontWeight: 600, + }, + h3: { + fontSize: '1.4em', + lineHeight: '1.08em', + paddingTop: '0.389em', + fontWeight: 600, + }, + paragraph: { + fontSize: '1em', + paddingTop: '0.5em', + paddingBottom: '0.5em', + }, + list: { + paddingLeft: '1.1em', + paddingBottom: '1em', + }, + bulletList: { + listStyleType: 'disc', + }, + orderedList: { + listStyleType: 'decimal', + }, + nestedList: { + paddingLeft: '1.1em', + paddingBottom: '0', + }, + listItem: { + marginLeft: '1em', + paddingBottom: '0.3em', + paddingTop: '0.3em', + }, + listParagraph: { padding: '0', margin: '0' }, + blockquote: { + borderLeft: '3px solid #acb3be', + color: '#7e8a9a', + marginLeft: 0, + paddingLeft: '0.8em', + fontSize: '1.1em', + fontFamily: + "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", + }, + link: { color: '#0670DB', textDecoration: 'underline' }, + footer: { + fontSize: '0.8em', + }, + hr: { + paddingBottom: '1em', + borderStyle: 'solid', + borderWidth: 0, + borderTopWidth: '2px', + }, + image: { + maxWidth: '100%', + }, + button: { + lineHeight: '100%', + display: 'inline-block', + paddingTop: '0.625em', + paddingRight: '1.25em', + paddingBottom: '0.625em', + paddingLeft: '1.25em', + backgroundColor: '#000000', + color: '#ffffff', + borderRadius: '0.375em', + fontWeight: 500, + fontSize: '0.875em', + textDecoration: 'none', + textAlign: 'center', + }, + inlineCode: { + paddingTop: '0.25em', + paddingBottom: '0.25em', + paddingLeft: '0.4em', + paddingRight: '0.4em', + background: '#e5e7eb', + color: '#1e293b', + borderRadius: '4px', + }, + codeBlock: { + background: '#f1f5f9', + borderRadius: '4px', + paddingTop: '0.75rem', + paddingRight: '1rem', + paddingBottom: '0.75rem', + paddingLeft: '1rem', + overflowX: 'auto', + fontFamily: 'monospace', + fontWeight: '500', + fontSize: '.92em', + }, + codeTag: { + lineHeight: '130%', + fontFamily: 'monospace', + fontSize: '.92em', + }, + section: { + padding: '10px 20px 10px 20px', + boxSizing: 'border-box' as const, + }, +}; + +const RESET_MINIMAL: ResetTheme = { + ...Object.keys(RESET_BASIC).reduce((acc, key) => { + acc[key as keyof ResetTheme] = {}; + return acc; + }, {} as ResetTheme), + reset: RESET_BASIC.reset, + button: { + lineHeight: RESET_BASIC.button.lineHeight, + display: RESET_BASIC.button.display, + }, + image: RESET_BASIC.image, + list: RESET_BASIC.list, + bulletList: RESET_BASIC.bulletList, + orderedList: RESET_BASIC.orderedList, + nestedList: RESET_BASIC.nestedList, + listItem: RESET_BASIC.listItem, + listParagraph: RESET_BASIC.listParagraph, + link: RESET_BASIC.link, +}; + +export const RESET_THEMES: Record = { + basic: RESET_BASIC, + minimal: RESET_MINIMAL, +}; + +export function resolveResetValue( + value: string | number | undefined, + targetUnit: 'px' | '%', + bodyFontSizePx: number, +): number | undefined { + if (value === undefined) { + return undefined; + } + const str = String(value); + const num = Number.parseFloat(str); + if (Number.isNaN(num)) { + return undefined; + } + if (str.endsWith('em')) { + return targetUnit === 'px' ? Math.floor(num * bodyFontSizePx) : num * 100; + } + return num; +} + +export const EDITOR_THEMES: Record = { + minimal: THEME_MINIMAL, + basic: THEME_BASIC, +}; + +export function getThemeBodyFontSizePx(theme: EditorTheme): number { + for (const group of EDITOR_THEMES[theme]) { + if (group.classReference !== 'body') { + continue; + } + for (const input of group.inputs) { + if (input.prop === 'fontSize' && typeof input.value === 'number') { + return input.value; + } + } + } + return 14; +} + +/** + * Use to make the preview nicer once the theme might miss some + * important properties to make layout accurate + */ +export const DEFAULT_INBOX_FONT_SIZE_PX = 14; +export const INBOX_EMAIL_DEFAULTS: Partial = { + body: { + color: '#000000', + fontFamily: + "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", + fontSize: `${DEFAULT_INBOX_FONT_SIZE_PX}px`, + }, + container: { + width: 600, + }, +}; + +export const SUPPORTED_CSS_PROPERTIES: SupportedCssProperties = { + align: { + label: 'Align', + type: 'select', + options: { + left: 'Left', + center: 'Center', + right: 'Right', + }, + defaultValue: 'left', + category: 'layout', + }, + backgroundColor: { + label: 'Background', + type: 'color', + excludeNodes: ['image', 'youtube'], + defaultValue: '#ffffff', + category: 'appearance', + }, + color: { + label: 'Text color', + type: 'color', + excludeNodes: ['image', 'youtube'], + defaultValue: '#000000', + category: 'typography', + }, + fontSize: { + label: 'Font size', + type: 'number', + unit: 'px', + excludeNodes: ['image', 'youtube'], + defaultValue: 14, + category: 'typography', + }, + fontWeight: { + label: 'Font weight', + type: 'select', + options: { + 300: 'Light', + 400: 'Normal', + 600: 'Semi Bold', + 700: 'Bold', + 800: 'Extra Bold', + }, + excludeNodes: ['image', 'youtube'], + defaultValue: 400, + category: 'typography', + }, + letterSpacing: { + label: 'Letter spacing', + type: 'number', + unit: 'px', + excludeNodes: ['image', 'youtube'], + defaultValue: 0, + category: 'typography', + }, + lineHeight: { + label: 'Line height', + type: 'number', + unit: '%', + defaultValue: 155, + category: 'typography', + }, + textDecoration: { + label: 'Text decoration', + type: 'select', + options: { + none: 'None', + underline: 'Underline', + 'line-through': 'Line-through', + }, + defaultValue: 'none', + category: 'typography', + }, + borderRadius: { + label: 'Border Radius', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'appearance', + }, + borderTopLeftRadius: { + label: 'Border Radius (Top-Left)', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'appearance', + }, + borderTopRightRadius: { + label: 'Border Radius (Top-Right)', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'appearance', + }, + borderBottomLeftRadius: { + label: 'Border Radius (Bottom-Left)', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'appearance', + }, + borderBottomRightRadius: { + label: 'Border Radius (Bottom-Right)', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'appearance', + }, + borderWidth: { + label: 'Border Width', + type: 'number', + unit: 'px', + defaultValue: 1, + category: 'appearance', + }, + borderTopWidth: { + label: 'Border Top Width', + type: 'number', + unit: 'px', + defaultValue: 1, + category: 'appearance', + }, + borderRightWidth: { + label: 'Border Right Width', + type: 'number', + unit: 'px', + defaultValue: 1, + category: 'appearance', + }, + borderBottomWidth: { + label: 'Border Bottom Width', + type: 'number', + unit: 'px', + defaultValue: 1, + category: 'appearance', + }, + borderLeftWidth: { + label: 'Border Left Width', + type: 'number', + unit: 'px', + defaultValue: 1, + category: 'appearance', + }, + borderStyle: { + label: 'Border Style', + type: 'select', + options: { + solid: 'Solid', + dashed: 'Dashed', + dotted: 'Dotted', + }, + defaultValue: 'solid', + category: 'appearance', + }, + borderTopStyle: { + label: 'Border Top Style', + type: 'select', + options: { + solid: 'Solid', + dashed: 'Dashed', + dotted: 'Dotted', + }, + defaultValue: 'solid', + category: 'appearance', + }, + borderRightStyle: { + label: 'Border Right Style', + type: 'select', + options: { + solid: 'Solid', + dashed: 'Dashed', + dotted: 'Dotted', + }, + defaultValue: 'solid', + category: 'appearance', + }, + borderBottomStyle: { + label: 'Border Bottom Style', + type: 'select', + options: { + solid: 'Solid', + dashed: 'Dashed', + dotted: 'Dotted', + }, + defaultValue: 'solid', + category: 'appearance', + }, + borderLeftStyle: { + label: 'Border Left Style', + type: 'select', + options: { + solid: 'Solid', + dashed: 'Dashed', + dotted: 'Dotted', + }, + defaultValue: 'solid', + category: 'appearance', + }, + borderColor: { + label: 'Border Color', + type: 'color', + defaultValue: '#000000', + category: 'appearance', + }, + borderTopColor: { + label: 'Border Top Color', + type: 'color', + defaultValue: '#000000', + category: 'appearance', + }, + borderRightColor: { + label: 'Border Right Color', + type: 'color', + defaultValue: '#000000', + category: 'appearance', + }, + borderBottomColor: { + label: 'Border Bottom Color', + type: 'color', + defaultValue: '#000000', + category: 'appearance', + }, + borderLeftColor: { + label: 'Border Left Color', + type: 'color', + defaultValue: '#000000', + category: 'appearance', + }, + padding: { + label: 'Padding', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'layout', + }, + paddingTop: { + label: 'Padding Top', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'layout', + }, + paddingLeft: { + label: 'Padding Left', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'layout', + }, + paddingBottom: { + label: 'Padding Bottom', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'layout', + }, + paddingRight: { + label: 'Padding Right', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'layout', + }, + width: { + label: 'Width', + type: 'number', + unit: 'px', + defaultValue: 600, + category: 'layout', + }, + height: { + label: 'Height', + type: 'number', + unit: 'px', + defaultValue: 400, + category: 'layout', + }, +}; diff --git a/packages/editor/src/plugins/email-theming/types.ts b/packages/editor/src/plugins/email-theming/types.ts new file mode 100644 index 0000000000..4a7d93397c --- /dev/null +++ b/packages/editor/src/plugins/email-theming/types.ts @@ -0,0 +1,161 @@ +import type * as React from 'react'; + +type InputType = 'color' | 'number' | 'select' | 'text' | 'textarea'; +type InputUnit = 'px' | '%'; +type Options = Record; + +export type EditorTheme = 'basic' | 'minimal'; +export type PanelSectionId = + | 'body' + | 'container' + | 'typography' + | 'h1' + | 'h2' + | 'h3' + | 'paragraph' + | 'list' + | 'nested-list' + | 'list-item' + | 'link' + | 'image' + | 'button' + | 'code-block' + | 'inline-code'; +export type KnownThemeComponents = + | 'reset' + | 'body' + | 'container' + | 'h1' + | 'h2' + | 'h3' + | 'paragraph' + | 'nestedList' + | 'list' + | 'bulletList' + | 'orderedList' + | 'listItem' + | 'listParagraph' + | 'blockquote' + | 'codeBlock' + | 'inlineCode' + | 'codeTag' + | 'link' + | 'footer' + | 'hr' + | 'image' + | 'button' + | 'section'; + +export type KnownCssProperties = + | 'align' + | 'backgroundColor' + | 'color' + | 'fontSize' + | 'fontWeight' + | 'letterSpacing' + | 'lineHeight' + | 'textDecoration' + | 'borderRadius' + | 'borderTopLeftRadius' + | 'borderTopRightRadius' + | 'borderBottomLeftRadius' + | 'borderBottomRightRadius' + | 'borderWidth' + | 'borderTopWidth' + | 'borderRightWidth' + | 'borderBottomWidth' + | 'borderLeftWidth' + | 'borderStyle' + | 'borderTopStyle' + | 'borderRightStyle' + | 'borderBottomStyle' + | 'borderLeftStyle' + | 'borderColor' + | 'borderTopColor' + | 'borderRightColor' + | 'borderBottomColor' + | 'borderLeftColor' + | 'padding' + | 'paddingTop' + | 'paddingRight' + | 'paddingBottom' + | 'paddingLeft' + | 'width' + | 'height'; + +export type ResetTheme = Record; + +export type CssJs = { + [K in KnownThemeComponents]: React.CSSProperties & { + // TODO: remove align as soon as possible + align?: 'center' | 'left' | 'right'; + }; +}; +export type SupportedCssProperties = { + [K in KnownCssProperties]: { + category: 'layout' | 'appearance' | 'typography'; + label: string; + type: InputType; + defaultValue: string | number; + unit?: InputUnit; + options?: Options; + excludeNodes?: string[]; + placeholder?: string; + customUpdate?: ( + props: Record, + update: (func: (tree: PanelGroup[]) => PanelGroup[]) => void, + ) => void; + }; +}; + +export interface PanelInputProperty { + label: string; + type: InputType; + value?: string | number; + prop: KnownCssProperties; + classReference?: KnownThemeComponents; + unit?: InputUnit; + options?: Options; + placeholder?: string; + category: SupportedCssProperties[KnownCssProperties]['category']; +} + +export interface PanelGroup { + id?: PanelSectionId; + title: string; + category?: string; + headerSlot?: React.ReactNode; + classReference?: KnownThemeComponents; + inputs: Omit[]; +} + +export type ThemeableComponent = Extract< + KnownThemeComponents, + | 'body' + | 'container' + | 'h1' + | 'h2' + | 'h3' + | 'link' + | 'image' + | 'button' + | 'codeBlock' + | 'inlineCode' + | 'listItem' + | 'list' + | 'nestedList' + | 'paragraph' +>; + +export type ThemeComponentStyles = { + [K in ThemeableComponent]?: React.CSSProperties & { + align?: 'center' | 'left' | 'right'; + }; +}; + +export interface ThemeConfig { + extends?: EditorTheme; + styles: ThemeComponentStyles; +} + +export type EditorThemeInput = EditorTheme | ThemeConfig; diff --git a/packages/editor/src/plugins/image/extension.spec.tsx b/packages/editor/src/plugins/image/extension.spec.tsx new file mode 100644 index 0000000000..74a836c1fd --- /dev/null +++ b/packages/editor/src/plugins/image/extension.spec.tsx @@ -0,0 +1,100 @@ +import type { Editor } from '@tiptap/core'; +import type { NodeType } from '@tiptap/pm/model'; +import { render } from 'react-email'; +import { describe, expect, it, vi } from 'vitest'; +import { createImageExtension } from './extension'; + +describe('Image extension', () => { + const uploadImage = async () => ({ url: '' }); + const extension = createImageExtension({ uploadImage }); + const renderToReactEmail = + (extension.options as any).renderToReactEmail ?? + extension.config.renderToReactEmail; + const extensionContext = { + name: extension.name, + options: extension.options, + storage: extension.storage as Record, + editor: {} as Editor, + type: {} as NodeType, + parent: undefined, + }; + + it('renders basic image', async () => { + const Component = () => + renderToReactEmail({ + node: { + type: { name: 'image' }, + attrs: { + src: 'https://example.com/img.png', + alt: 'Test image', + width: '600', + height: 'auto', + alignment: 'center', + href: null, + }, + }, + style: {}, + extension, + }); + + const html = await render(, { pretty: true }); + expect(html).toContain('src="https://example.com/img.png"'); + expect(html).toContain('alt="Test image"'); + }); + + it('wraps image in link when href is set', async () => { + const Component = () => + renderToReactEmail({ + node: { + type: { name: 'image' }, + attrs: { + src: 'https://example.com/img.png', + alt: '', + width: 'auto', + height: 'auto', + alignment: 'center', + href: 'https://example.com', + }, + }, + style: {}, + extension, + }); + + const html = await render(, { pretty: true }); + expect(html).toContain('href="https://example.com"'); + expect(html).toContain('src="https://example.com/img.png"'); + }); + + it('defines expected attributes', () => { + const attrs = extension.config.addAttributes?.call(extensionContext) ?? {}; + expect(attrs).toHaveProperty('src'); + expect(attrs).toHaveProperty('alt'); + expect(attrs).toHaveProperty('width'); + expect(attrs).toHaveProperty('height'); + expect(attrs).toHaveProperty('alignment'); + expect(attrs).toHaveProperty('href'); + }); + + it('returns correct node config', () => { + const ext = createImageExtension({ + uploadImage: vi.fn().mockResolvedValue({ url: '' }), + }); + + expect(ext.name).toBe('image'); + expect(ext.config.atom).toBe(true); + expect(ext.config.draggable).toBe(true); + expect(ext.config.group).toBe('block'); + }); + + it('has setImage and uploadImage commands', () => { + const commands = extension.config.addCommands?.call(extensionContext); + expect(commands).toHaveProperty('setImage'); + expect(commands).toHaveProperty('uploadImage'); + }); + + it('registers the image file handler plugin', () => { + const plugins = + extension.config.addProseMirrorPlugins?.call(extensionContext); + expect(plugins).toHaveLength(1); + }); +}); diff --git a/packages/editor/src/plugins/image/extension.tsx b/packages/editor/src/plugins/image/extension.tsx new file mode 100644 index 0000000000..69bb8fb09a --- /dev/null +++ b/packages/editor/src/plugins/image/extension.tsx @@ -0,0 +1,92 @@ +import { Img, Link } from 'react-email'; +import { EmailNode } from '../../core/serializer/email-node'; +import { createImageFileHandlerPlugin } from './file-handler'; +import type { UseEditorImageOptions } from './types'; +import { executeUploadFlow } from './upload-flow'; + +export function createImageExtension(options: UseEditorImageOptions) { + return EmailNode.create({ + name: 'image', + group: 'block', + atom: true, + draggable: true, + + addAttributes() { + return { + src: { default: '' }, + alt: { default: '' }, + width: { default: 'auto' }, + height: { default: 'auto' }, + alignment: { default: 'center' }, + href: { default: null }, + }; + }, + + parseHTML() { + return [{ tag: 'img[src]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['img', HTMLAttributes]; + }, + + addCommands() { + return { + setImage: + (attrs) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs, + }); + }, + + uploadImage: + () => + ({ editor }) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = () => { + const file = input.files?.[0]; + if (file) { + void executeUploadFlow({ + editor, + file, + uploadImage: options.uploadImage, + }); + } + }; + input.click(); + return true; + }, + }; + }, + + addProseMirrorPlugins() { + const { editor } = this; + + return [createImageFileHandlerPlugin(editor, options.uploadImage)]; + }, + + renderToReactEmail: ({ node, style }) => { + const img = ( + {node.attrs?.alt + ); + + if (node.attrs?.href) { + return {img}; + } + + return img; + }, + }); +} diff --git a/packages/editor/src/plugins/image/file-handler.ts b/packages/editor/src/plugins/image/file-handler.ts new file mode 100644 index 0000000000..81755b6722 --- /dev/null +++ b/packages/editor/src/plugins/image/file-handler.ts @@ -0,0 +1,41 @@ +import type { Editor } from '@tiptap/core'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import type { UseEditorImageOptions } from './types'; +import { executeUploadFlow } from './upload-flow'; + +export function createImageFileHandlerPlugin( + editor: Editor, + uploadImage: UseEditorImageOptions['uploadImage'], +) { + return new Plugin({ + key: new PluginKey('imageFileHandler'), + props: { + handlePaste(_view, event) { + const file = event.clipboardData?.files?.[0]; + if (!file?.type.includes('image/')) { + return false; + } + + event.preventDefault(); + void executeUploadFlow({ editor, file, uploadImage }); + + return true; + }, + handleDrop(_view, event, _slice, moved) { + if (moved || !event.dataTransfer?.files?.[0]) { + return false; + } + + const file = event.dataTransfer.files[0]; + if (!file.type.includes('image/')) { + return false; + } + + event.preventDefault(); + void executeUploadFlow({ editor, file, uploadImage }); + + return true; + }, + }, + }); +} diff --git a/packages/editor/src/plugins/image/index.ts b/packages/editor/src/plugins/image/index.ts new file mode 100644 index 0000000000..8014a95ed5 --- /dev/null +++ b/packages/editor/src/plugins/image/index.ts @@ -0,0 +1,35 @@ +import { useMemo, useRef } from 'react'; +import { createImageExtension } from './extension'; +import type { UseEditorImageOptions } from './types'; + +declare module '@tiptap/core' { + interface Commands { + image: { + setImage: (attrs: { + src: string; + alt?: string; + width?: string; + height?: string; + alignment?: string; + href?: string; + }) => ReturnType; + uploadImage: () => ReturnType; + }; + } +} + +export { imageSlashCommand } from './slash-command'; +export type { UploadImageResult, UseEditorImageOptions } from './types'; + +export function useEditorImage(options: UseEditorImageOptions) { + const uploadImageRef = useRef(options.uploadImage); + uploadImageRef.current = options.uploadImage; + + return useMemo( + () => + createImageExtension({ + uploadImage: (file) => uploadImageRef.current(file), + }), + [], + ); +} diff --git a/packages/editor/src/plugins/image/slash-command.tsx b/packages/editor/src/plugins/image/slash-command.tsx new file mode 100644 index 0000000000..52c9fcdf89 --- /dev/null +++ b/packages/editor/src/plugins/image/slash-command.tsx @@ -0,0 +1,14 @@ +import { ImageIcon } from '../../ui/icons/image'; +import type { SlashCommandItem } from '../../ui/slash-command/types'; + +export const imageSlashCommand: SlashCommandItem = { + title: 'Image', + description: 'Upload an image', + icon: , + category: 'Layout', + searchTerms: ['image', 'img', 'picture', 'photo', 'upload'], + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).run(); + editor.commands.uploadImage(); + }, +}; diff --git a/packages/editor/src/plugins/image/types.ts b/packages/editor/src/plugins/image/types.ts new file mode 100644 index 0000000000..b0a6cfe22f --- /dev/null +++ b/packages/editor/src/plugins/image/types.ts @@ -0,0 +1,7 @@ +export interface UploadImageResult { + url: string; +} + +export interface UseEditorImageOptions { + uploadImage: (file: File) => Promise; +} diff --git a/packages/editor/src/plugins/image/upload-flow.ts b/packages/editor/src/plugins/image/upload-flow.ts new file mode 100644 index 0000000000..9a86f37b20 --- /dev/null +++ b/packages/editor/src/plugins/image/upload-flow.ts @@ -0,0 +1,69 @@ +import type { Editor } from '@tiptap/core'; +import type { UseEditorImageOptions } from './types'; + +interface ExecuteUploadFlowParams { + editor: Editor; + file: File; + uploadImage: UseEditorImageOptions['uploadImage']; +} + +export async function executeUploadFlow({ + editor, + file, + uploadImage, +}: ExecuteUploadFlowParams): Promise { + const blobUrl = URL.createObjectURL(file); + + editor.chain().focus().setImage({ src: blobUrl }).run(); + + try { + const { url } = await uploadImage(file); + swapImageSrc(editor, blobUrl, url); + } catch (error) { + removeImageBySrc(editor, blobUrl); + console.error( + `Failed to upload image "${file.name}":`, + error instanceof Error ? error : new Error(String(error)), + ); + } finally { + URL.revokeObjectURL(blobUrl); + } +} + +function swapImageSrc(editor: Editor, oldSrc: string, newSrc: string): void { + const { state } = editor; + const { tr } = state; + let found = false; + + state.doc.descendants((node, pos) => { + if (found) return false; + if (node.type.name === 'image' && node.attrs.src === oldSrc) { + tr.setNodeMarkup(pos, undefined, { ...node.attrs, src: newSrc }); + found = true; + return false; + } + }); + + if (found) { + editor.view.dispatch(tr); + } +} + +function removeImageBySrc(editor: Editor, src: string): void { + const { state } = editor; + const { tr } = state; + let found = false; + + state.doc.descendants((node, pos) => { + if (found) return false; + if (node.type.name === 'image' && node.attrs.src === src) { + tr.delete(pos, pos + node.nodeSize); + found = true; + return false; + } + }); + + if (found) { + editor.view.dispatch(tr); + } +} diff --git a/packages/editor/src/plugins/index.ts b/packages/editor/src/plugins/index.ts new file mode 100644 index 0000000000..4a651a2f0e --- /dev/null +++ b/packages/editor/src/plugins/index.ts @@ -0,0 +1,2 @@ +export * from './email-theming'; +export * from './image'; diff --git a/packages/editor/src/ui/bubble-menu/align-center.tsx b/packages/editor/src/ui/bubble-menu/align-center.tsx new file mode 100644 index 0000000000..3b8f6cf6b4 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/align-center.tsx @@ -0,0 +1,30 @@ +import { useEditorState } from '@tiptap/react'; +import { setTextAlignment } from '../../utils/set-text-alignment'; +import { AlignCenterIcon } from '../icons'; +import { useBubbleMenuContext } from './context'; +import type { PreWiredItemProps } from './create-mark-bubble-item'; +import { BubbleMenuItem } from './item'; + +export function BubbleMenuAlignCenter({ + className, + children, +}: PreWiredItemProps) { + const { editor } = useBubbleMenuContext(); + + const isActive = useEditorState({ + editor, + selector: ({ editor }) => + editor?.isActive({ alignment: 'center' }) ?? false, + }); + + return ( + setTextAlignment(editor, 'center')} + className={className} + > + {children ?? } + + ); +} diff --git a/packages/editor/src/ui/bubble-menu/align-left.tsx b/packages/editor/src/ui/bubble-menu/align-left.tsx new file mode 100644 index 0000000000..b32dc75195 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/align-left.tsx @@ -0,0 +1,29 @@ +import { useEditorState } from '@tiptap/react'; +import { setTextAlignment } from '../../utils/set-text-alignment'; +import { AlignLeftIcon } from '../icons'; +import { useBubbleMenuContext } from './context'; +import type { PreWiredItemProps } from './create-mark-bubble-item'; +import { BubbleMenuItem } from './item'; + +export function BubbleMenuAlignLeft({ + className, + children, +}: PreWiredItemProps) { + const { editor } = useBubbleMenuContext(); + + const isActive = useEditorState({ + editor, + selector: ({ editor }) => editor?.isActive({ alignment: 'left' }) ?? false, + }); + + return ( + setTextAlignment(editor, 'left')} + className={className} + > + {children ?? } + + ); +} diff --git a/packages/editor/src/ui/bubble-menu/align-right.tsx b/packages/editor/src/ui/bubble-menu/align-right.tsx new file mode 100644 index 0000000000..16f52b7c11 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/align-right.tsx @@ -0,0 +1,29 @@ +import { useEditorState } from '@tiptap/react'; +import { setTextAlignment } from '../../utils/set-text-alignment'; +import { AlignRightIcon } from '../icons'; +import { useBubbleMenuContext } from './context'; +import type { PreWiredItemProps } from './create-mark-bubble-item'; +import { BubbleMenuItem } from './item'; + +export function BubbleMenuAlignRight({ + className, + children, +}: PreWiredItemProps) { + const { editor } = useBubbleMenuContext(); + + const isActive = useEditorState({ + editor, + selector: ({ editor }) => editor?.isActive({ alignment: 'right' }) ?? false, + }); + + return ( + setTextAlignment(editor, 'right')} + className={className} + > + {children ?? } + + ); +} diff --git a/packages/editor/src/ui/bubble-menu/bold.tsx b/packages/editor/src/ui/bubble-menu/bold.tsx new file mode 100644 index 0000000000..05a7c250c0 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/bold.tsx @@ -0,0 +1,9 @@ +import { BoldIcon } from '../icons'; +import { createMarkBubbleItem } from './create-mark-bubble-item'; + +export const BubbleMenuBold = createMarkBubbleItem({ + name: 'bold', + activeName: 'bold', + command: 'toggleBold', + icon: , +}); diff --git a/packages/editor/src/ui/bubble-menu/bubble-menu.css b/packages/editor/src/ui/bubble-menu/bubble-menu.css new file mode 100644 index 0000000000..17a24e2944 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/bubble-menu.css @@ -0,0 +1,326 @@ +/* Minimal functional styles for BubbleMenu compound components. + * This file handles layout and positioning only - no visual design. + * Import this optionally: import '@react-email/editor/styles/bubble-menu.css'; + */ + +[data-re-bubble-menu] { + display: flex; + align-items: center; + gap: 0.125rem; +} + +[data-re-bubble-menu-group] { + display: flex; + align-items: center; + gap: 0.125rem; + padding: 0 0.125rem; + border: none; + border-left: 1px solid var(--re-border); + margin: 0; + min-width: 0; +} + +[data-re-bubble-menu-group]:last-child { + padding-right: 0; +} + +[data-re-bubble-menu-separator] { + align-self: stretch; + width: 1px; + margin: 0.25rem 0; +} + +[data-re-bubble-menu-item] { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: none; + background: none; + padding: 0.375rem; + margin: 0.125rem 0; +} + +[data-re-bubble-menu-item] svg { + width: 0.875rem; + height: 0.875rem; +} + +[data-re-node-selector] { + position: relative; +} + +[data-re-node-selector-trigger] { + display: flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; + border: none; + background: none; + white-space: nowrap; + font-size: 0.8125rem; + padding: 0.375rem 0.5rem; +} + +[data-re-node-selector-trigger] svg { + width: 0.75rem; + height: 0.75rem; + opacity: 0.5; +} + +[data-re-node-selector-content] { + display: flex; + flex-direction: column; + min-width: 10rem; +} + +[data-re-node-selector-item] { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + border: none; + background: none; + padding: 0.375rem 0.5rem; + font-size: 0.8125rem; + width: 100%; + text-align: left; +} + +[data-re-node-selector-item] svg { + width: 0.875rem; + height: 0.875rem; +} + +[data-re-link-selector] { + display: flex; + position: relative; +} + +[data-re-link-selector-trigger] { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: none; + background: none; + padding: 0.375rem; +} + +[data-re-link-selector-trigger] svg { + width: 0.875rem; + height: 0.875rem; +} + +[data-re-link-selector-form] { + display: flex; + align-items: center; + gap: 0.25rem; + position: absolute; + top: 100%; + left: 0; + margin-top: 0.25rem; + width: max-content; + min-width: 16rem; + padding: 0.25rem; +} + +[data-re-link-selector-input] { + flex: 1; + border: none; + outline: none; + font-size: 0.8125rem; + padding: 0.25rem; + background: transparent; +} + +[data-re-link-selector-apply], +[data-re-link-selector-unlink] { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: none; + background: none; + padding: 0.25rem; +} + +[data-re-link-selector-apply] svg, +[data-re-link-selector-unlink] svg { + width: 0.875rem; + height: 0.875rem; +} + +/* Button bubble menu */ + +[data-re-btn-bm-toolbar] { + display: flex; + align-items: center; +} + +[data-re-btn-bm-item] { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: none; + background: none; + padding: 0.375rem; +} + +[data-re-btn-bm-item] svg { + width: 0.875rem; + height: 0.875rem; +} + +[data-re-btn-bm-form] { + display: flex; + align-items: center; + gap: 0.25rem; + min-width: 16rem; + padding: 0.25rem; +} + +[data-re-btn-bm-input] { + flex: 1; + border: none; + outline: none; + font-size: 0.8125rem; + padding: 0.25rem; + background: transparent; +} + +[data-re-btn-bm-apply], +[data-re-btn-bm-unlink] { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: none; + background: none; + padding: 0.25rem; +} + +[data-re-btn-bm-apply] svg, +[data-re-btn-bm-unlink] svg { + width: 0.875rem; + height: 0.875rem; +} + +/* Link bubble menu */ + +[data-re-link-bm-toolbar] { + display: flex; + align-items: center; +} + +[data-re-link-bm-item] { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: none; + background: none; + padding: 0.375rem; +} + +[data-re-link-bm-item] svg { + width: 0.875rem; + height: 0.875rem; +} + +a[data-re-link-bm-item] { + text-decoration: none; + color: inherit; +} + +[data-re-link-bm-form] { + display: flex; + align-items: center; + gap: 0.25rem; + min-width: 16rem; + padding: 0.25rem; +} + +[data-re-link-bm-input] { + flex: 1; + border: none; + outline: none; + font-size: 0.8125rem; + padding: 0.25rem; + background: transparent; +} + +[data-re-link-bm-apply], +[data-re-link-bm-unlink] { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: none; + background: none; + padding: 0.25rem; +} + +[data-re-link-bm-apply] svg, +[data-re-link-bm-unlink] svg { + width: 0.875rem; + height: 0.875rem; +} + +/* Image bubble menu */ + +[data-re-img-bm-toolbar] { + display: flex; + align-items: center; +} + +[data-re-img-bm-item] { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: none; + background: none; + padding: 0.375rem; + margin: 0.125rem 0; +} + +[data-re-img-bm-item] svg { + width: 0.875rem; + height: 0.875rem; +} + +[data-re-img-bm-form] { + display: flex; + align-items: center; + gap: 0.25rem; + min-width: 16rem; + padding: 0.25rem; +} + +[data-re-img-bm-input] { + flex: 1; + border: none; + outline: none; + font-size: 0.8125rem; + padding: 0.25rem; + background: transparent; +} + +[data-re-img-bm-apply], +[data-re-img-bm-unlink] { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: none; + background: none; + padding: 0.25rem; +} + +[data-re-img-bm-apply] svg, +[data-re-img-bm-unlink] svg { + width: 0.875rem; + height: 0.875rem; +} diff --git a/packages/editor/src/ui/bubble-menu/button-default.tsx b/packages/editor/src/ui/bubble-menu/button-default.tsx new file mode 100644 index 0000000000..e60e412cac --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/button-default.tsx @@ -0,0 +1,82 @@ +import { PluginKey } from '@tiptap/pm/state'; +import { useEditorState } from '@tiptap/react'; +import type * as React from 'react'; +import { BubbleMenuButtonEditLink } from './button-edit-link'; +import { BubbleMenuButtonForm } from './button-form'; +import { BubbleMenuButtonToolbar } from './button-toolbar'; +import { BubbleMenuButtonUnlink } from './button-unlink'; +import { useBubbleMenuContext } from './context'; +import { BubbleMenuRoot } from './root'; +import { bubbleMenuTriggers } from './triggers'; + +const buttonPluginKey = new PluginKey('buttonBubbleMenu'); + +export interface BubbleMenuButtonDefaultProps + extends Omit, 'children'> { + placement?: 'top' | 'bottom'; + offset?: number; + onHide?: () => void; + validateUrl?: (value: string) => string | null; + onLinkApply?: (href: string) => void; + onLinkRemove?: () => void; +} + +function BubbleMenuButtonDefaultInner({ + validateUrl, + onLinkApply, + onLinkRemove, +}: Pick< + BubbleMenuButtonDefaultProps, + 'validateUrl' | 'onLinkApply' | 'onLinkRemove' +>) { + const { editor } = useBubbleMenuContext(); + const buttonHref = useEditorState({ + editor, + selector: ({ editor: e }) => + (e?.getAttributes('button').href as string) ?? '', + }); + const hasLink = (buttonHref ?? '') !== '' && buttonHref !== '#'; + + return ( + <> + + + {hasLink && } + + + + ); +} + +export function BubbleMenuButtonDefault({ + placement = 'top', + offset, + onHide, + className, + validateUrl, + onLinkApply, + onLinkRemove, + ...rest +}: BubbleMenuButtonDefaultProps) { + return ( + + + + ); +} diff --git a/packages/editor/src/ui/bubble-menu/button-edit-link.tsx b/packages/editor/src/ui/bubble-menu/button-edit-link.tsx new file mode 100644 index 0000000000..745269939d --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/button-edit-link.tsx @@ -0,0 +1,37 @@ +import type * as React from 'react'; +import { PencilIcon } from '../icons'; +import { useBubbleMenuContext } from './context'; + +export interface BubbleMenuButtonEditLinkProps + extends Omit, 'type'> {} + +export function BubbleMenuButtonEditLink({ + className, + children, + onClick, + onMouseDown, + ...rest +}: BubbleMenuButtonEditLinkProps) { + const { setIsEditing } = useBubbleMenuContext(); + + return ( + + ); +} diff --git a/packages/editor/src/ui/bubble-menu/button-form.tsx b/packages/editor/src/ui/bubble-menu/button-form.tsx new file mode 100644 index 0000000000..6cf82e9a98 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/button-form.tsx @@ -0,0 +1,155 @@ +import * as React from 'react'; +import { CheckIcon, UnlinkIcon } from '../icons'; +import { useBubbleMenuContext } from './context'; +import { focusEditor, getUrlFromString } from './utils'; + +export interface BubbleMenuButtonFormProps { + className?: string; + validateUrl?: (value: string) => string | null; + onLinkApply?: (href: string) => void; + onLinkRemove?: () => void; +} + +export function BubbleMenuButtonForm({ + className, + validateUrl, + onLinkApply, + onLinkRemove, +}: BubbleMenuButtonFormProps) { + const { editor, isEditing, setIsEditing } = useBubbleMenuContext(); + const inputRef = React.useRef(null); + const formRef = React.useRef(null); + + const buttonHref = (editor.getAttributes('button').href as string) ?? ''; + const displayHref = buttonHref === '#' ? '' : buttonHref; + const [inputValue, setInputValue] = React.useState(displayHref); + + React.useEffect(() => { + if (!isEditing) { + return; + } + const currentHref = (editor.getAttributes('button').href as string) ?? ''; + const display = currentHref === '#' ? '' : currentHref; + setInputValue(display); + const timeoutId = setTimeout(() => { + inputRef.current?.focus(); + }, 0); + return () => clearTimeout(timeoutId); + }, [isEditing, editor]); + + React.useEffect(() => { + if (!isEditing) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEditing(false); + } + }; + + const handleClickOutside = (event: MouseEvent) => { + if (formRef.current && !formRef.current.contains(event.target as Node)) { + const form = formRef.current; + const submitEvent = new Event('submit', { + bubbles: true, + cancelable: true, + }); + form.dispatchEvent(submitEvent); + setIsEditing(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isEditing, setIsEditing]); + + if (!isEditing) { + return null; + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + const value = inputValue.trim(); + + if (value === '') { + editor.commands.updateButton({ href: '#' }); + setIsEditing(false); + focusEditor(editor); + onLinkRemove?.(); + return; + } + + const validate = validateUrl ?? getUrlFromString; + const finalValue = validate(value); + + if (!finalValue) { + editor.commands.updateButton({ href: '#' }); + setIsEditing(false); + focusEditor(editor); + onLinkRemove?.(); + return; + } + + editor.commands.updateButton({ href: finalValue }); + setIsEditing(false); + focusEditor(editor); + onLinkApply?.(finalValue); + } + + function handleUnlink(e: React.MouseEvent) { + e.stopPropagation(); + editor.commands.updateButton({ href: '#' }); + setIsEditing(false); + focusEditor(editor); + onLinkRemove?.(); + } + + return ( +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + onSubmit={handleSubmit} + > + e.stopPropagation()} + onChange={(e) => setInputValue(e.target.value)} + placeholder="Paste a link" + type="text" + /> + + {displayHref ? ( + + ) : ( + + )} +
+ ); +} diff --git a/packages/editor/src/ui/bubble-menu/button-toolbar.tsx b/packages/editor/src/ui/bubble-menu/button-toolbar.tsx new file mode 100644 index 0000000000..a30421bdeb --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/button-toolbar.tsx @@ -0,0 +1,22 @@ +import type * as React from 'react'; +import { useBubbleMenuContext } from './context'; + +export interface BubbleMenuButtonToolbarProps + extends React.ComponentProps<'div'> {} + +export function BubbleMenuButtonToolbar({ + children, + ...rest +}: BubbleMenuButtonToolbarProps) { + const { isEditing } = useBubbleMenuContext(); + + if (isEditing) { + return null; + } + + return ( +
+ {children} +
+ ); +} diff --git a/packages/editor/src/ui/bubble-menu/button-unlink.tsx b/packages/editor/src/ui/bubble-menu/button-unlink.tsx new file mode 100644 index 0000000000..57de22025a --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/button-unlink.tsx @@ -0,0 +1,43 @@ +import type * as React from 'react'; +import { UnlinkIcon } from '../icons'; +import { useBubbleMenuContext } from './context'; +import { focusEditor } from './utils'; + +export interface BubbleMenuButtonUnlinkProps + extends Omit, 'type'> { + onLinkRemove?: () => void; +} + +export function BubbleMenuButtonUnlink({ + className, + children, + onClick, + onMouseDown, + onLinkRemove, + ...rest +}: BubbleMenuButtonUnlinkProps) { + const { editor } = useBubbleMenuContext(); + + return ( + + ); +} diff --git a/packages/editor/src/ui/bubble-menu/code.tsx b/packages/editor/src/ui/bubble-menu/code.tsx new file mode 100644 index 0000000000..cc0f4e0ef1 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/code.tsx @@ -0,0 +1,9 @@ +import { CodeIcon } from '../icons'; +import { createMarkBubbleItem } from './create-mark-bubble-item'; + +export const BubbleMenuCode = createMarkBubbleItem({ + name: 'code', + activeName: 'code', + command: 'toggleCode', + icon: , +}); diff --git a/packages/editor/src/ui/bubble-menu/context.tsx b/packages/editor/src/ui/bubble-menu/context.tsx new file mode 100644 index 0000000000..eb3396bcda --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/context.tsx @@ -0,0 +1,21 @@ +import type { Editor } from '@tiptap/core'; +import * as React from 'react'; + +export interface BubbleMenuContextValue { + editor: Editor; + isEditing: boolean; + setIsEditing: (value: boolean) => void; +} + +export const BubbleMenuContext = + React.createContext(null); + +export function useBubbleMenuContext(): BubbleMenuContextValue { + const context = React.useContext(BubbleMenuContext); + if (!context) { + throw new Error( + 'BubbleMenu compound components must be used within ', + ); + } + return context; +} diff --git a/packages/editor/src/ui/bubble-menu/create-mark-bubble-item.tsx b/packages/editor/src/ui/bubble-menu/create-mark-bubble-item.tsx new file mode 100644 index 0000000000..476a9895f5 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/create-mark-bubble-item.tsx @@ -0,0 +1,61 @@ +import { useEditorState } from '@tiptap/react'; +import type * as React from 'react'; +import { useBubbleMenuContext } from './context'; +import { BubbleMenuItem } from './item'; + +export interface PreWiredItemProps { + className?: string; + /** Override the default icon */ + children?: React.ReactNode; +} + +interface MarkBubbleItemConfig { + name: string; + activeName: string; + activeParams?: Record; + command: string; + icon: React.ReactNode; +} + +export function createMarkBubbleItem(config: MarkBubbleItemConfig) { + function MarkBubbleItem({ className, children }: PreWiredItemProps) { + const { editor } = useBubbleMenuContext(); + + const isActive = useEditorState({ + editor, + selector: ({ editor }) => { + if (config.activeParams) { + return ( + editor?.isActive(config.activeName, config.activeParams) ?? false + ); + } + return editor?.isActive(config.activeName) ?? false; + }, + }); + + const handleCommand = () => { + const chain = editor.chain().focus(); + const method = (chain as unknown as Record typeof chain>)[ + config.command + ]; + if (method) { + method.call(chain).run(); + } + }; + + return ( + + {children ?? config.icon} + + ); + } + + MarkBubbleItem.displayName = `BubbleMenu${config.name.charAt(0).toUpperCase() + config.name.slice(1)}`; + + return MarkBubbleItem; +} diff --git a/packages/editor/src/ui/bubble-menu/group.spec.tsx b/packages/editor/src/ui/bubble-menu/group.spec.tsx new file mode 100644 index 0000000000..c6f9295a37 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/group.spec.tsx @@ -0,0 +1,42 @@ +import { render, screen } from '@testing-library/react'; +import { BubbleMenuItemGroup } from './group'; +import { BubbleMenuSeparator } from './separator'; + +describe('BubbleMenuItemGroup', () => { + it('renders children with correct data attribute and role', () => { + render( + + + , + ); + const group = screen.getByRole('group'); + expect(group).toBeDefined(); + expect(group.dataset.reBubbleMenuGroup).toBeDefined(); + expect(group.textContent).toBe('Bold'); + }); + + it('applies className', () => { + render( + + + , + ); + const group = screen.getByRole('group'); + expect(group.className).toBe('custom-class'); + }); +}); + +describe('BubbleMenuSeparator', () => { + it('renders a separator with correct data attribute', () => { + render(); + const separator = screen.getByRole('separator'); + expect(separator).toBeDefined(); + expect(separator.dataset.reBubbleMenuSeparator).toBeDefined(); + }); + + it('applies className', () => { + render(); + const separator = screen.getByRole('separator'); + expect(separator.className).toBe('divider'); + }); +}); diff --git a/packages/editor/src/ui/bubble-menu/group.tsx b/packages/editor/src/ui/bubble-menu/group.tsx new file mode 100644 index 0000000000..51c61920b1 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/group.tsx @@ -0,0 +1,17 @@ +import type * as React from 'react'; + +export interface BubbleMenuItemGroupProps { + className?: string; + children: React.ReactNode; +} + +export function BubbleMenuItemGroup({ + className, + children, +}: BubbleMenuItemGroupProps) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/editor/src/ui/bubble-menu/image-default.tsx b/packages/editor/src/ui/bubble-menu/image-default.tsx new file mode 100644 index 0000000000..3462133360 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/image-default.tsx @@ -0,0 +1,97 @@ +import { PluginKey } from '@tiptap/pm/state'; +import { useEditorState } from '@tiptap/react'; +import type * as React from 'react'; +import { useBubbleMenuContext } from './context'; +import { BubbleMenuImageEditLink } from './image-edit-link'; +import { BubbleMenuImageForm } from './image-form'; +import { BubbleMenuImageToolbar } from './image-toolbar'; +import { BubbleMenuImageUnlink } from './image-unlink'; +import { BubbleMenuRoot } from './root'; +import { bubbleMenuTriggers } from './triggers'; + +const imagePluginKey = new PluginKey('imageBubbleMenu'); + +type ExcludableItem = 'edit-link' | 'unlink'; + +export interface BubbleMenuImageDefaultProps + extends Omit, 'children'> { + excludeItems?: ExcludableItem[]; + placement?: 'top' | 'bottom'; + offset?: number; + onHide?: () => void; + validateUrl?: (value: string) => string | null; + onLinkApply?: (href: string) => void; + onLinkRemove?: () => void; +} + +function BubbleMenuImageDefaultInner({ + excludeItems, + validateUrl, + onLinkApply, + onLinkRemove, +}: Pick< + BubbleMenuImageDefaultProps, + 'excludeItems' | 'validateUrl' | 'onLinkApply' | 'onLinkRemove' +> & { excludeItems: ExcludableItem[] }) { + const { editor } = useBubbleMenuContext(); + const imageHref = useEditorState({ + editor, + selector: ({ editor: e }) => + (e?.getAttributes('image').href as string | null) ?? '', + }); + + const has = (item: ExcludableItem) => !excludeItems.includes(item); + const hasLink = (imageHref ?? '') !== ''; + const showEditLink = has('edit-link'); + const showUnlink = has('unlink') && hasLink; + const hasToolbarItems = showEditLink || showUnlink; + + return ( + <> + {hasToolbarItems && ( + + {showEditLink && } + {showUnlink && } + + )} + {showEditLink && ( + + )} + + ); +} + +export function BubbleMenuImageDefault({ + excludeItems = [], + placement = 'top', + offset, + onHide, + className, + validateUrl, + onLinkApply, + onLinkRemove, + ...rest +}: BubbleMenuImageDefaultProps) { + return ( + + + + ); +} diff --git a/packages/editor/src/ui/bubble-menu/image-edit-link.tsx b/packages/editor/src/ui/bubble-menu/image-edit-link.tsx new file mode 100644 index 0000000000..7f756b788a --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/image-edit-link.tsx @@ -0,0 +1,37 @@ +import type * as React from 'react'; +import { PencilIcon } from '../icons'; +import { useBubbleMenuContext } from './context'; + +export interface BubbleMenuImageEditLinkProps + extends Omit, 'type'> {} + +export function BubbleMenuImageEditLink({ + className, + children, + onClick, + onMouseDown, + ...rest +}: BubbleMenuImageEditLinkProps) { + const { setIsEditing } = useBubbleMenuContext(); + + return ( + + ); +} diff --git a/packages/editor/src/ui/bubble-menu/image-form.tsx b/packages/editor/src/ui/bubble-menu/image-form.tsx new file mode 100644 index 0000000000..8999d271d3 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/image-form.tsx @@ -0,0 +1,164 @@ +import { useEditorState } from '@tiptap/react'; +import * as React from 'react'; +import { CheckIcon, UnlinkIcon } from '../icons'; +import { useBubbleMenuContext } from './context'; +import { focusEditor, getUrlFromString } from './utils'; + +export interface BubbleMenuImageFormProps { + className?: string; + validateUrl?: (value: string) => string | null; + onLinkApply?: (href: string) => void; + onLinkRemove?: () => void; +} + +export function BubbleMenuImageForm({ + className, + validateUrl, + onLinkApply, + onLinkRemove, +}: BubbleMenuImageFormProps) { + const { editor, isEditing, setIsEditing } = useBubbleMenuContext(); + const inputRef = React.useRef(null); + const formRef = React.useRef(null); + + const imageHref = useEditorState({ + editor, + selector: ({ editor: e }) => + (e?.getAttributes('image').href as string | null) ?? '', + }); + + const [inputValue, setInputValue] = React.useState(imageHref ?? ''); + + React.useEffect(() => { + if (!isEditing) { + return; + } + setInputValue(imageHref ?? ''); + const timeoutId = setTimeout(() => { + inputRef.current?.focus(); + }, 0); + return () => clearTimeout(timeoutId); + }, [isEditing, imageHref]); + + React.useEffect(() => { + if (!isEditing) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEditing(false); + } + }; + + const handleClickOutside = (event: MouseEvent) => { + if (formRef.current && !formRef.current.contains(event.target as Node)) { + const form = formRef.current; + const submitEvent = new Event('submit', { + bubbles: true, + cancelable: true, + }); + form.dispatchEvent(submitEvent); + setIsEditing(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isEditing, setIsEditing]); + + if (!isEditing) { + return null; + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + const value = inputValue.trim(); + + if (value === '') { + editor.chain().focus().updateAttributes('image', { href: null }).run(); + setIsEditing(false); + focusEditor(editor); + onLinkRemove?.(); + return; + } + + const validate = validateUrl ?? getUrlFromString; + const finalValue = validate(value); + + if (!finalValue) { + editor.chain().focus().updateAttributes('image', { href: null }).run(); + setIsEditing(false); + focusEditor(editor); + onLinkRemove?.(); + return; + } + + editor + .chain() + .focus() + .updateAttributes('image', { href: finalValue }) + .run(); + setIsEditing(false); + focusEditor(editor); + onLinkApply?.(finalValue); + } + + function handleUnlink(e: React.MouseEvent) { + e.stopPropagation(); + editor.chain().focus().updateAttributes('image', { href: null }).run(); + setIsEditing(false); + focusEditor(editor); + onLinkRemove?.(); + } + + const hasLink = (imageHref ?? '') !== ''; + + return ( +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + onSubmit={handleSubmit} + > + e.stopPropagation()} + onChange={(e) => setInputValue(e.target.value)} + placeholder="Paste a link" + type="text" + /> + + {hasLink ? ( + + ) : ( + + )} +
+ ); +} diff --git a/packages/editor/src/ui/bubble-menu/image-toolbar.tsx b/packages/editor/src/ui/bubble-menu/image-toolbar.tsx new file mode 100644 index 0000000000..bdec0ad54e --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/image-toolbar.tsx @@ -0,0 +1,22 @@ +import type * as React from 'react'; +import { useBubbleMenuContext } from './context'; + +export interface BubbleMenuImageToolbarProps + extends React.ComponentProps<'div'> {} + +export function BubbleMenuImageToolbar({ + children, + ...rest +}: BubbleMenuImageToolbarProps) { + const { isEditing } = useBubbleMenuContext(); + + if (isEditing) { + return null; + } + + return ( +
+ {children} +
+ ); +} diff --git a/packages/editor/src/ui/bubble-menu/image-unlink.tsx b/packages/editor/src/ui/bubble-menu/image-unlink.tsx new file mode 100644 index 0000000000..36c2b7dbf6 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/image-unlink.tsx @@ -0,0 +1,43 @@ +import type * as React from 'react'; +import { UnlinkIcon } from '../icons'; +import { useBubbleMenuContext } from './context'; +import { focusEditor } from './utils'; + +export interface BubbleMenuImageUnlinkProps + extends Omit, 'type'> { + onLinkRemove?: () => void; +} + +export function BubbleMenuImageUnlink({ + className, + children, + onClick, + onMouseDown, + onLinkRemove, + ...rest +}: BubbleMenuImageUnlinkProps) { + const { editor } = useBubbleMenuContext(); + + return ( + + ); +} diff --git a/packages/editor/src/ui/bubble-menu/index.ts b/packages/editor/src/ui/bubble-menu/index.ts new file mode 100644 index 0000000000..879206c505 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/index.ts @@ -0,0 +1,146 @@ +import { BubbleMenuAlignCenter } from './align-center'; +import { BubbleMenuAlignLeft } from './align-left'; +import { BubbleMenuAlignRight } from './align-right'; +import { BubbleMenuBold } from './bold'; +import { BubbleMenuButtonDefault } from './button-default'; +import { BubbleMenuButtonEditLink } from './button-edit-link'; +import { BubbleMenuButtonForm } from './button-form'; +import { BubbleMenuButtonToolbar } from './button-toolbar'; +import { BubbleMenuButtonUnlink } from './button-unlink'; +import { BubbleMenuCode } from './code'; +import { BubbleMenuItemGroup } from './group'; +import { BubbleMenuImageDefault } from './image-default'; +import { BubbleMenuImageEditLink } from './image-edit-link'; +import { BubbleMenuImageForm } from './image-form'; +import { BubbleMenuImageToolbar } from './image-toolbar'; +import { BubbleMenuImageUnlink } from './image-unlink'; +import { BubbleMenuItalic } from './italic'; +import { BubbleMenuItem } from './item'; +import { BubbleMenuLinkDefault } from './link-default'; +import { BubbleMenuLinkEditLink } from './link-edit-link'; +import { BubbleMenuLinkForm } from './link-form'; +import { BubbleMenuLinkOpenLink } from './link-open-link'; +import { BubbleMenuLinkSelector } from './link-selector'; +import { BubbleMenuLinkToolbar } from './link-toolbar'; +import { BubbleMenuLinkUnlink } from './link-unlink'; +import { + BubbleMenuNodeSelector, + NodeSelectorContent, + NodeSelectorRoot, + NodeSelectorTrigger, +} from './node-selector'; +import { BubbleMenuRoot } from './root'; +import { BubbleMenuSeparator } from './separator'; +import { BubbleMenuStrike } from './strike'; +import { BubbleMenuUnderline } from './underline'; +import { BubbleMenuUppercase } from './uppercase'; + +// Named exports +export { BubbleMenuAlignCenter } from './align-center'; +export { BubbleMenuAlignLeft } from './align-left'; +export { BubbleMenuAlignRight } from './align-right'; +export { BubbleMenuBold } from './bold'; +export type { BubbleMenuButtonDefaultProps } from './button-default'; +export { BubbleMenuButtonDefault } from './button-default'; +export type { BubbleMenuButtonEditLinkProps } from './button-edit-link'; +export { BubbleMenuButtonEditLink } from './button-edit-link'; +export type { BubbleMenuButtonFormProps } from './button-form'; +export { BubbleMenuButtonForm } from './button-form'; +export type { BubbleMenuButtonToolbarProps } from './button-toolbar'; +export { BubbleMenuButtonToolbar } from './button-toolbar'; +export type { BubbleMenuButtonUnlinkProps } from './button-unlink'; +export { BubbleMenuButtonUnlink } from './button-unlink'; +export { BubbleMenuCode } from './code'; +export type { BubbleMenuContextValue } from './context'; +export { useBubbleMenuContext } from './context'; +export type { PreWiredItemProps } from './create-mark-bubble-item'; +export type { BubbleMenuItemGroupProps } from './group'; +export { BubbleMenuItemGroup } from './group'; +export type { BubbleMenuImageDefaultProps } from './image-default'; +export { BubbleMenuImageDefault } from './image-default'; +export type { BubbleMenuImageEditLinkProps } from './image-edit-link'; +export { BubbleMenuImageEditLink } from './image-edit-link'; +export type { BubbleMenuImageFormProps } from './image-form'; +export { BubbleMenuImageForm } from './image-form'; +export type { BubbleMenuImageToolbarProps } from './image-toolbar'; +export { BubbleMenuImageToolbar } from './image-toolbar'; +export type { BubbleMenuImageUnlinkProps } from './image-unlink'; +export { BubbleMenuImageUnlink } from './image-unlink'; +export { BubbleMenuItalic } from './italic'; +export type { BubbleMenuItemProps } from './item'; +export { BubbleMenuItem } from './item'; +export type { BubbleMenuLinkDefaultProps } from './link-default'; +export { BubbleMenuLinkDefault } from './link-default'; +export type { BubbleMenuLinkEditLinkProps } from './link-edit-link'; +export { BubbleMenuLinkEditLink } from './link-edit-link'; +export type { BubbleMenuLinkFormProps } from './link-form'; +export { BubbleMenuLinkForm } from './link-form'; +export type { BubbleMenuLinkOpenLinkProps } from './link-open-link'; +export { BubbleMenuLinkOpenLink } from './link-open-link'; +export type { BubbleMenuLinkSelectorProps } from './link-selector'; +export { BubbleMenuLinkSelector } from './link-selector'; +export type { BubbleMenuLinkToolbarProps } from './link-toolbar'; +export { BubbleMenuLinkToolbar } from './link-toolbar'; +export type { BubbleMenuLinkUnlinkProps } from './link-unlink'; +export { BubbleMenuLinkUnlink } from './link-unlink'; +export type { + BubbleMenuNodeSelectorProps, + NodeSelectorContentProps, + NodeSelectorItem, + NodeSelectorRootProps, + NodeSelectorTriggerProps, + NodeType, +} from './node-selector'; +export { + BubbleMenuNodeSelector, + NodeSelectorContent, + NodeSelectorRoot, + NodeSelectorTrigger, +} from './node-selector'; +export type { BubbleMenuRootProps } from './root'; +export { BubbleMenuRoot } from './root'; +export type { BubbleMenuSeparatorProps } from './separator'; +export { BubbleMenuSeparator } from './separator'; +export { BubbleMenuStrike } from './strike'; +export type { TriggerFn, TriggerParams } from './triggers'; +export { bubbleMenuTriggers } from './triggers'; +export { BubbleMenuUnderline } from './underline'; +export { BubbleMenuUppercase } from './uppercase'; + +export const BubbleMenu = Object.assign(BubbleMenuRoot, { + Root: BubbleMenuRoot, + ItemGroup: BubbleMenuItemGroup, + Separator: BubbleMenuSeparator, + Item: BubbleMenuItem, + Bold: BubbleMenuBold, + Italic: BubbleMenuItalic, + Underline: BubbleMenuUnderline, + Strike: BubbleMenuStrike, + Code: BubbleMenuCode, + Uppercase: BubbleMenuUppercase, + AlignLeft: BubbleMenuAlignLeft, + AlignCenter: BubbleMenuAlignCenter, + AlignRight: BubbleMenuAlignRight, + NodeSelector: Object.assign(BubbleMenuNodeSelector, { + Root: NodeSelectorRoot, + Trigger: NodeSelectorTrigger, + Content: NodeSelectorContent, + }), + LinkSelector: BubbleMenuLinkSelector, + ButtonToolbar: BubbleMenuButtonToolbar, + ButtonEditLink: BubbleMenuButtonEditLink, + ButtonUnlink: BubbleMenuButtonUnlink, + ButtonForm: BubbleMenuButtonForm, + ButtonDefault: BubbleMenuButtonDefault, + LinkToolbar: BubbleMenuLinkToolbar, + LinkEditLink: BubbleMenuLinkEditLink, + LinkUnlink: BubbleMenuLinkUnlink, + LinkOpenLink: BubbleMenuLinkOpenLink, + LinkForm: BubbleMenuLinkForm, + LinkDefault: BubbleMenuLinkDefault, + ImageToolbar: BubbleMenuImageToolbar, + ImageEditLink: BubbleMenuImageEditLink, + ImageUnlink: BubbleMenuImageUnlink, + ImageForm: BubbleMenuImageForm, + ImageDefault: BubbleMenuImageDefault, +} as const); diff --git a/packages/editor/src/ui/bubble-menu/italic.tsx b/packages/editor/src/ui/bubble-menu/italic.tsx new file mode 100644 index 0000000000..a9da36223c --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/italic.tsx @@ -0,0 +1,9 @@ +import { ItalicIcon } from '../icons'; +import { createMarkBubbleItem } from './create-mark-bubble-item'; + +export const BubbleMenuItalic = createMarkBubbleItem({ + name: 'italic', + activeName: 'italic', + command: 'toggleItalic', + icon: , +}); diff --git a/packages/editor/src/ui/bubble-menu/item.browser.spec.tsx b/packages/editor/src/ui/bubble-menu/item.browser.spec.tsx new file mode 100644 index 0000000000..8999e54fb3 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/item.browser.spec.tsx @@ -0,0 +1,42 @@ +import { page } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { BubbleMenuItem } from './item'; + +describe('BubbleMenuItem (browser)', () => { + it('renders with correct aria attributes when inactive', async () => { + render( + {}}> + B + , + ); + + const button = page.getByRole('button', { name: 'bold' }); + await expect.element(button).toBeVisible(); + await expect.element(button).toHaveAttribute('aria-pressed', 'false'); + }); + + it('sets aria-pressed when active', async () => { + render( + {}}> + B + , + ); + + const button = page.getByRole('button', { name: 'bold' }); + await expect.element(button).toHaveAttribute('aria-pressed', 'true'); + }); + + it('calls onCommand on click', async () => { + const onCommand = vi.fn(); + render( + + B + , + ); + + const button = page.getByRole('button', { name: 'bold' }); + await expect.element(button).toBeVisible(); + await button.click(); + expect(onCommand).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/editor/src/ui/bubble-menu/item.spec.tsx b/packages/editor/src/ui/bubble-menu/item.spec.tsx new file mode 100644 index 0000000000..9eeaeed1fa --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/item.spec.tsx @@ -0,0 +1,79 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { BubbleMenuItem } from './item'; + +describe('BubbleMenuItem', () => { + it('renders a button with correct aria attributes when inactive', () => { + const onCommand = vi.fn(); + render( + + B + , + ); + + const button = screen.getByRole('button', { name: 'bold' }); + expect(button).toBeDefined(); + expect(button.getAttribute('aria-pressed')).toBe('false'); + expect(button.dataset.reBubbleMenuItem).toBeDefined(); + expect(button.dataset.item).toBe('bold'); + expect(button.dataset.active).toBeUndefined(); + }); + + it('sets data-active and aria-pressed when active', () => { + render( + {}}> + B + , + ); + + const button = screen.getByRole('button', { name: 'bold' }); + expect(button.getAttribute('aria-pressed')).toBe('true'); + expect(button.dataset.active).toBeDefined(); + }); + + it('calls onCommand on click', () => { + const onCommand = vi.fn(); + render( + + B + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'bold' })); + expect(onCommand).toHaveBeenCalledOnce(); + }); + + it('applies className', () => { + render( + {}} + className="custom" + > + B + , + ); + + expect(screen.getByRole('button', { name: 'bold' }).className).toBe( + 'custom', + ); + }); + + it('spreads additional button props', () => { + render( + {}} + data-testid="custom-button" + disabled + > + B + , + ); + + const button = screen.getByTestId('custom-button'); + expect(button).toBeDefined(); + expect(button.getAttribute('disabled')).toBe(''); + }); +}); diff --git a/packages/editor/src/ui/bubble-menu/item.tsx b/packages/editor/src/ui/bubble-menu/item.tsx new file mode 100644 index 0000000000..293f71cae9 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/item.tsx @@ -0,0 +1,36 @@ +import type * as React from 'react'; + +export interface BubbleMenuItemProps extends React.ComponentProps<'button'> { + /** Used for aria-label and data-item attribute */ + name: string; + /** Whether this item is currently active */ + isActive: boolean; + /** Called when clicked */ + onCommand: () => void; +} + +export function BubbleMenuItem({ + name, + isActive, + onCommand, + className, + children, + ...rest +}: BubbleMenuItemProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/bubble-menu/link-default.tsx b/packages/editor/src/ui/bubble-menu/link-default.tsx new file mode 100644 index 0000000000..0011176434 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/link-default.tsx @@ -0,0 +1,65 @@ +import { PluginKey } from '@tiptap/pm/state'; +import type * as React from 'react'; +import { BubbleMenuLinkEditLink } from './link-edit-link'; +import { BubbleMenuLinkForm } from './link-form'; +import { BubbleMenuLinkOpenLink } from './link-open-link'; +import { BubbleMenuLinkToolbar } from './link-toolbar'; +import { BubbleMenuLinkUnlink } from './link-unlink'; +import { BubbleMenuRoot } from './root'; +import { bubbleMenuTriggers } from './triggers'; + +const linkPluginKey = new PluginKey('linkBubbleMenu'); + +type ExcludableItem = 'edit-link' | 'open-link' | 'unlink'; + +export interface BubbleMenuLinkDefaultProps + extends Omit, 'children'> { + excludeItems?: ExcludableItem[]; + placement?: 'top' | 'bottom'; + offset?: number; + onHide?: () => void; + validateUrl?: (value: string) => string | null; + onLinkApply?: (href: string) => void; + onLinkRemove?: () => void; +} + +export function BubbleMenuLinkDefault({ + excludeItems = [], + placement = 'top', + offset, + onHide, + className, + validateUrl, + onLinkApply, + onLinkRemove, + ...rest +}: BubbleMenuLinkDefaultProps) { + const has = (item: ExcludableItem) => !excludeItems.includes(item); + + const hasToolbarItems = has('edit-link') || has('open-link') || has('unlink'); + + return ( + + {hasToolbarItems && ( + + {has('edit-link') && } + {has('open-link') && } + {has('unlink') && } + + )} + + + ); +} diff --git a/packages/editor/src/ui/bubble-menu/link-edit-link.tsx b/packages/editor/src/ui/bubble-menu/link-edit-link.tsx new file mode 100644 index 0000000000..09e0bf7b2d --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/link-edit-link.tsx @@ -0,0 +1,37 @@ +import type * as React from 'react'; +import { PencilIcon } from '../icons'; +import { useBubbleMenuContext } from './context'; + +export interface BubbleMenuLinkEditLinkProps + extends Omit, 'type'> {} + +export function BubbleMenuLinkEditLink({ + className, + children, + onClick, + onMouseDown, + ...rest +}: BubbleMenuLinkEditLinkProps) { + const { setIsEditing } = useBubbleMenuContext(); + + return ( + + ); +} diff --git a/packages/editor/src/ui/bubble-menu/link-form.tsx b/packages/editor/src/ui/bubble-menu/link-form.tsx new file mode 100644 index 0000000000..3a3af55f31 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/link-form.tsx @@ -0,0 +1,163 @@ +import { useEditorState } from '@tiptap/react'; +import * as React from 'react'; +import { CheckIcon, UnlinkIcon } from '../icons'; +import { useBubbleMenuContext } from './context'; +import { focusEditor, getUrlFromString, setLinkHref } from './utils'; + +export interface BubbleMenuLinkFormProps { + className?: string; + validateUrl?: (value: string) => string | null; + onLinkApply?: (href: string) => void; + onLinkRemove?: () => void; + children?: React.ReactNode; +} + +export function BubbleMenuLinkForm({ + className, + validateUrl, + onLinkApply, + onLinkRemove, + children, +}: BubbleMenuLinkFormProps) { + const { editor, isEditing, setIsEditing } = useBubbleMenuContext(); + const inputRef = React.useRef(null); + const formRef = React.useRef(null); + + const linkHref = useEditorState({ + editor, + selector: ({ editor: e }) => + (e?.getAttributes('link').href as string) ?? '', + }); + + const displayHref = (linkHref ?? '') === '#' ? '' : (linkHref ?? ''); + const [inputValue, setInputValue] = React.useState(displayHref); + + React.useEffect(() => { + if (!isEditing) { + return; + } + setInputValue(displayHref); + const timeoutId = setTimeout(() => { + inputRef.current?.focus(); + }, 0); + return () => clearTimeout(timeoutId); + }, [isEditing, displayHref]); + + React.useEffect(() => { + if (!isEditing) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEditing(false); + } + }; + + const handleClickOutside = (event: MouseEvent) => { + if (formRef.current && !formRef.current.contains(event.target as Node)) { + const form = formRef.current; + const submitEvent = new Event('submit', { + bubbles: true, + cancelable: true, + }); + form.dispatchEvent(submitEvent); + setIsEditing(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isEditing, setIsEditing]); + + if (!isEditing) { + return null; + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + const value = inputValue.trim(); + + if (value === '') { + setLinkHref(editor, ''); + setIsEditing(false); + focusEditor(editor); + onLinkRemove?.(); + return; + } + + const validate = validateUrl ?? getUrlFromString; + const finalValue = validate(value); + + if (!finalValue) { + setLinkHref(editor, ''); + setIsEditing(false); + focusEditor(editor); + onLinkRemove?.(); + return; + } + + setLinkHref(editor, finalValue); + setIsEditing(false); + focusEditor(editor); + onLinkApply?.(finalValue); + } + + function handleUnlink(e: React.MouseEvent) { + e.stopPropagation(); + setLinkHref(editor, ''); + setIsEditing(false); + focusEditor(editor); + onLinkRemove?.(); + } + + return ( +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + onSubmit={handleSubmit} + > + e.stopPropagation()} + onChange={(e) => setInputValue(e.target.value)} + placeholder="Paste a link" + type="text" + /> + + {children} + + {displayHref ? ( + + ) : ( + + )} +
+ ); +} diff --git a/packages/editor/src/ui/bubble-menu/link-open-link.tsx b/packages/editor/src/ui/bubble-menu/link-open-link.tsx new file mode 100644 index 0000000000..57430e724d --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/link-open-link.tsx @@ -0,0 +1,36 @@ +import { useEditorState } from '@tiptap/react'; +import type * as React from 'react'; +import { ExternalLinkIcon } from '../icons'; +import { useBubbleMenuContext } from './context'; + +export interface BubbleMenuLinkOpenLinkProps + extends Omit, 'href' | 'target' | 'rel'> {} + +export function BubbleMenuLinkOpenLink({ + className, + children, + ...rest +}: BubbleMenuLinkOpenLinkProps) { + const { editor } = useBubbleMenuContext(); + + const linkHref = useEditorState({ + editor, + selector: ({ editor: e }) => + (e?.getAttributes('link').href as string) ?? '', + }); + + return ( + + {children ?? } + + ); +} diff --git a/packages/editor/src/ui/bubble-menu/link-selector.spec.tsx b/packages/editor/src/ui/bubble-menu/link-selector.spec.tsx new file mode 100644 index 0000000000..e34e5040cc --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/link-selector.spec.tsx @@ -0,0 +1,103 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { BubbleMenuLinkSelector } from './link-selector'; + +const mockEditor = { + isActive: vi.fn().mockReturnValue(false), + getAttributes: vi.fn().mockReturnValue({}), + chain: vi.fn().mockReturnValue({ + focus: vi.fn().mockReturnThis(), + unsetLink: vi.fn().mockReturnThis(), + setLink: vi.fn().mockReturnThis(), + extendMarkRange: vi.fn().mockReturnThis(), + setTextSelection: vi.fn().mockReturnThis(), + run: vi.fn(), + }), + state: { selection: { from: 0, to: 5 } }, + commands: { focus: vi.fn() }, +}; + +vi.mock('@tiptap/react', () => ({ + useEditorState: ({ + selector, + }: { + selector: (ctx: { editor: unknown }) => unknown; + }) => selector({ editor: mockEditor }), +})); + +vi.mock('./context', () => ({ + useBubbleMenuContext: () => ({ editor: mockEditor }), +})); + +vi.mock('../../core/event-bus', () => ({ + editorEventBus: { + on: () => ({ unsubscribe: vi.fn() }), + }, +})); + +afterEach(() => { + cleanup(); +}); + +describe('BubbleMenuLinkSelector', () => { + describe('uncontrolled mode (default)', () => { + it('toggles open state on trigger click', () => { + render(); + + expect(screen.queryByPlaceholderText('Paste a link')).toBeNull(); + + fireEvent.click(screen.getByLabelText('Add link')); + expect(screen.getByPlaceholderText('Paste a link')).toBeDefined(); + + fireEvent.click(screen.getByLabelText('Add link')); + expect(screen.queryByPlaceholderText('Paste a link')).toBeNull(); + }); + }); + + describe('controlled mode', () => { + it('renders open when open=true', () => { + render( {}} />); + + expect(screen.getByPlaceholderText('Paste a link')).toBeDefined(); + }); + + it('renders closed when open=false', () => { + render( {}} />); + + expect(screen.queryByPlaceholderText('Paste a link')).toBeNull(); + }); + + it('calls onOpenChange when trigger is clicked', () => { + const onOpenChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByLabelText('Add link')); + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + + it('calls onOpenChange with false when toggling off', () => { + const onOpenChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByLabelText('Add link')); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('does not update internal state in controlled mode', () => { + const { rerender } = render( + {}} />, + ); + + // Click trigger — in controlled mode, open stays false unless parent updates + fireEvent.click(screen.getByLabelText('Add link')); + expect(screen.queryByPlaceholderText('Paste a link')).toBeNull(); + + // Parent updates to open + rerender( {}} />); + expect(screen.getByPlaceholderText('Paste a link')).toBeDefined(); + }); + }); +}); diff --git a/packages/editor/src/ui/bubble-menu/link-selector.tsx b/packages/editor/src/ui/bubble-menu/link-selector.tsx new file mode 100644 index 0000000000..ce375efd59 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/link-selector.tsx @@ -0,0 +1,261 @@ +import type { Editor } from '@tiptap/core'; +import { useEditorState } from '@tiptap/react'; +import * as React from 'react'; +import { editorEventBus } from '../../core/event-bus'; +import { CheckIcon, LinkIcon, UnlinkIcon } from '../icons'; +import { useBubbleMenuContext } from './context'; +import { focusEditor, getUrlFromString, setLinkHref } from './utils'; + +export interface BubbleMenuLinkSelectorProps { + className?: string; + /** Whether to show the link icon toggle button (default: true) */ + showToggle?: boolean; + /** Custom URL validator. Return the valid URL string or null. */ + validateUrl?: (value: string) => string | null; + /** Called after link is applied */ + onLinkApply?: (href: string) => void; + /** Called after link is removed */ + onLinkRemove?: () => void; + /** Plugin slot: extra actions rendered inside the link input form */ + children?: React.ReactNode; + /** Controlled open state */ + open?: boolean; + /** Called when open state changes */ + onOpenChange?: (open: boolean) => void; +} + +export function BubbleMenuLinkSelector({ + className, + showToggle = true, + validateUrl, + onLinkApply, + onLinkRemove, + children, + open: controlledOpen, + onOpenChange, +}: BubbleMenuLinkSelectorProps) { + const { editor } = useBubbleMenuContext(); + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false); + + const isControlled = controlledOpen !== undefined; + const isOpen = isControlled ? controlledOpen : uncontrolledOpen; + const setIsOpen = React.useCallback( + (value: boolean) => { + if (!isControlled) { + setUncontrolledOpen(value); + } + onOpenChange?.(value); + }, + [isControlled, onOpenChange], + ); + + const editorState = useEditorState({ + editor, + selector: ({ editor }) => ({ + isLinkActive: editor?.isActive('link') ?? false, + hasLink: Boolean(editor?.getAttributes('link').href), + currentHref: (editor?.getAttributes('link').href as string) || '', + }), + }); + + const setIsOpenRef = React.useRef(setIsOpen); + setIsOpenRef.current = setIsOpen; + + React.useEffect(() => { + const subscription = editorEventBus.on('bubble-menu:add-link', () => { + setIsOpenRef.current(true); + }); + + return () => { + setIsOpenRef.current(false); + subscription.unsubscribe(); + }; + }, []); + + if (!editorState) { + return null; + } + + const handleOpenLink = () => { + setIsOpen(!isOpen); + }; + + return ( +
+ {showToggle && ( + + )} + {isOpen && ( + + {children} + + )} +
+ ); +} + +interface LinkFormProps { + editor: Editor; + currentHref: string; + validateUrl?: (value: string) => string | null; + onLinkApply?: (href: string) => void; + onLinkRemove?: () => void; + setIsOpen: (state: boolean) => void; + children?: React.ReactNode; +} + +function LinkForm({ + editor, + currentHref, + validateUrl, + onLinkApply, + onLinkRemove, + setIsOpen, + children, +}: LinkFormProps) { + const inputRef = React.useRef(null); + const formRef = React.useRef(null); + const displayHref = currentHref === '#' ? '' : currentHref; + const [inputValue, setInputValue] = React.useState(displayHref); + + React.useEffect(() => { + const timeoutId = setTimeout(() => { + inputRef.current?.focus(); + }, 0); + return () => clearTimeout(timeoutId); + }, []); + + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + if (editor.getAttributes('link').href === '#') { + editor.chain().unsetLink().run(); + } + setIsOpen(false); + } + }; + + const handleClickOutside = (event: MouseEvent) => { + if (formRef.current && !formRef.current.contains(event.target as Node)) { + const form = formRef.current; + const submitEvent = new Event('submit', { + bubbles: true, + cancelable: true, + }); + form.dispatchEvent(submitEvent); + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [editor, setIsOpen]); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + const value = inputValue.trim(); + + if (value === '') { + setLinkHref(editor, ''); + setIsOpen(false); + focusEditor(editor); + onLinkRemove?.(); + return; + } + + const validate = validateUrl ?? getUrlFromString; + const finalValue = validate(value); + + if (!finalValue) { + setLinkHref(editor, ''); + setIsOpen(false); + focusEditor(editor); + onLinkRemove?.(); + return; + } + + setLinkHref(editor, finalValue); + setIsOpen(false); + focusEditor(editor); + onLinkApply?.(finalValue); + } + + function handleUnlink(e: React.MouseEvent) { + e.stopPropagation(); + setLinkHref(editor, ''); + setIsOpen(false); + focusEditor(editor); + onLinkRemove?.(); + } + + return ( +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + onSubmit={handleSubmit} + > + e.stopPropagation()} + onChange={(e) => setInputValue(e.target.value)} + placeholder="Paste a link" + type="text" + /> + + {children} + + {displayHref ? ( + + ) : ( + + )} +
+ ); +} diff --git a/packages/editor/src/ui/bubble-menu/link-toolbar.tsx b/packages/editor/src/ui/bubble-menu/link-toolbar.tsx new file mode 100644 index 0000000000..6a1f8213b0 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/link-toolbar.tsx @@ -0,0 +1,22 @@ +import type * as React from 'react'; +import { useBubbleMenuContext } from './context'; + +export interface BubbleMenuLinkToolbarProps + extends React.ComponentProps<'div'> {} + +export function BubbleMenuLinkToolbar({ + children, + ...rest +}: BubbleMenuLinkToolbarProps) { + const { isEditing } = useBubbleMenuContext(); + + if (isEditing) { + return null; + } + + return ( +
+ {children} +
+ ); +} diff --git a/packages/editor/src/ui/bubble-menu/link-unlink.tsx b/packages/editor/src/ui/bubble-menu/link-unlink.tsx new file mode 100644 index 0000000000..bb52a6d170 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/link-unlink.tsx @@ -0,0 +1,37 @@ +import type * as React from 'react'; +import { UnlinkIcon } from '../icons'; +import { useBubbleMenuContext } from './context'; + +export interface BubbleMenuLinkUnlinkProps + extends Omit, 'type'> {} + +export function BubbleMenuLinkUnlink({ + className, + children, + onClick, + onMouseDown, + ...rest +}: BubbleMenuLinkUnlinkProps) { + const { editor } = useBubbleMenuContext(); + + return ( + + ); +} diff --git a/packages/editor/src/ui/bubble-menu/node-selector.tsx b/packages/editor/src/ui/bubble-menu/node-selector.tsx new file mode 100644 index 0000000000..f50bb72db1 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/node-selector.tsx @@ -0,0 +1,326 @@ +import * as Popover from '@radix-ui/react-popover'; +import { useEditorState } from '@tiptap/react'; +import * as React from 'react'; +import { EditorFocusScope } from '../editor-focus-scope'; +import { + CheckIcon, + ChevronDownIcon, + CodeIcon, + Heading1Icon, + Heading2Icon, + Heading3Icon, + ListIcon, + ListOrderedIcon, + TextIcon, + TextQuoteIcon, +} from '../icons'; +import { useBubbleMenuContext } from './context'; + +export type NodeType = + | 'Text' + | 'Title' + | 'Subtitle' + | 'Heading' + | 'Bullet List' + | 'Numbered List' + | 'Quote' + | 'Code'; + +export interface NodeSelectorItem { + name: NodeType; + icon: React.ComponentType>; + command: () => void; + isActive: boolean; +} + +interface NodeSelectorContextValue { + items: NodeSelectorItem[]; + activeItem: NodeSelectorItem | { name: 'Multiple' }; + isOpen: boolean; + setIsOpen: (value: boolean) => void; +} + +const NodeSelectorContext = + React.createContext(null); + +function useNodeSelectorContext(): NodeSelectorContextValue { + const context = React.useContext(NodeSelectorContext); + if (!context) { + throw new Error( + 'NodeSelector compound components must be used within ', + ); + } + return context; +} + +export interface NodeSelectorRootProps { + /** Block types to exclude */ + omit?: string[]; + /** Controlled open state */ + open?: boolean; + /** Called when open state changes */ + onOpenChange?: (open: boolean) => void; + className?: string; + children: React.ReactNode; +} + +export function NodeSelectorRoot({ + omit = [], + open: controlledOpen, + onOpenChange, + className, + children, +}: NodeSelectorRootProps) { + const { editor } = useBubbleMenuContext(); + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false); + + const isControlled = controlledOpen !== undefined; + const isOpen = isControlled ? controlledOpen : uncontrolledOpen; + const setIsOpen = React.useCallback( + (value: boolean) => { + if (!isControlled) { + setUncontrolledOpen(value); + } + onOpenChange?.(value); + }, + [isControlled, onOpenChange], + ); + + const editorState = useEditorState({ + editor, + selector: ({ editor }) => ({ + isParagraphActive: + (editor?.isActive('paragraph') ?? false) && + !editor?.isActive('bulletList') && + !editor?.isActive('orderedList'), + isHeading1Active: editor?.isActive('heading', { level: 1 }) ?? false, + isHeading2Active: editor?.isActive('heading', { level: 2 }) ?? false, + isHeading3Active: editor?.isActive('heading', { level: 3 }) ?? false, + isBulletListActive: editor?.isActive('bulletList') ?? false, + isOrderedListActive: editor?.isActive('orderedList') ?? false, + isBlockquoteActive: editor?.isActive('blockquote') ?? false, + isCodeBlockActive: editor?.isActive('codeBlock') ?? false, + }), + }); + + const allItems: NodeSelectorItem[] = React.useMemo( + () => [ + { + name: 'Text' as const, + icon: TextIcon, + command: () => + editor + .chain() + .focus() + .clearNodes() + .toggleNode('paragraph', 'paragraph') + .run(), + isActive: editorState?.isParagraphActive ?? false, + }, + { + name: 'Title' as const, + icon: Heading1Icon, + command: () => + editor.chain().focus().clearNodes().toggleHeading({ level: 1 }).run(), + isActive: editorState?.isHeading1Active ?? false, + }, + { + name: 'Subtitle' as const, + icon: Heading2Icon, + command: () => + editor.chain().focus().clearNodes().toggleHeading({ level: 2 }).run(), + isActive: editorState?.isHeading2Active ?? false, + }, + { + name: 'Heading' as const, + icon: Heading3Icon, + command: () => + editor.chain().focus().clearNodes().toggleHeading({ level: 3 }).run(), + isActive: editorState?.isHeading3Active ?? false, + }, + { + name: 'Bullet List' as const, + icon: ListIcon, + command: () => + editor.chain().focus().clearNodes().toggleBulletList().run(), + isActive: editorState?.isBulletListActive ?? false, + }, + { + name: 'Numbered List' as const, + icon: ListOrderedIcon, + command: () => + editor.chain().focus().clearNodes().toggleOrderedList().run(), + isActive: editorState?.isOrderedListActive ?? false, + }, + { + name: 'Quote' as const, + icon: TextQuoteIcon, + command: () => + editor + .chain() + .focus() + .clearNodes() + .toggleNode('paragraph', 'paragraph') + .toggleBlockquote() + .run(), + isActive: editorState?.isBlockquoteActive ?? false, + }, + { + name: 'Code' as const, + icon: CodeIcon, + command: () => + editor.chain().focus().clearNodes().toggleCodeBlock().run(), + isActive: editorState?.isCodeBlockActive ?? false, + }, + ], + [editor, editorState], + ); + + const items = React.useMemo( + () => allItems.filter((item) => !omit.includes(item.name)), + [allItems, omit], + ); + + const activeItem = React.useMemo( + () => + items.find((item) => item.isActive) ?? { + name: 'Multiple' as const, + }, + [items], + ); + + const contextValue = React.useMemo( + () => ({ items, activeItem, isOpen, setIsOpen }), + [items, activeItem, isOpen, setIsOpen], + ); + + if (!editorState || items.length === 0) { + return null; + } + + return ( + + + +
+ {children} +
+
+
+
+ ); +} + +export interface NodeSelectorTriggerProps { + className?: string; + children?: React.ReactNode; +} + +export function NodeSelectorTrigger({ + className, + children, +}: NodeSelectorTriggerProps) { + const { activeItem, isOpen, setIsOpen } = useNodeSelectorContext(); + + return ( + setIsOpen(!isOpen)} + > + {children ?? ( + <> + {activeItem.name} + + + )} + + ); +} + +export interface NodeSelectorContentProps { + className?: string; + /** Popover alignment (default: "start") */ + align?: 'start' | 'center' | 'end'; + /** Render-prop for full control over item rendering. + * Receives the filtered items and a `close` function to dismiss the popover. */ + children?: (items: NodeSelectorItem[], close: () => void) => React.ReactNode; +} + +export function NodeSelectorContent({ + className, + align = 'start', + children, +}: NodeSelectorContentProps) { + const { items, setIsOpen } = useNodeSelectorContext(); + + return ( + + +
+ {children + ? children(items, () => setIsOpen(false)) + : items.map((item) => { + const Icon = item.icon; + return ( + + ); + })} +
+
+
+ ); +} + +export interface BubbleMenuNodeSelectorProps { + /** Block types to exclude */ + omit?: string[]; + className?: string; + /** Override the trigger content (default: active item name + chevron icon) */ + triggerContent?: React.ReactNode; + /** Controlled open state */ + open?: boolean; + /** Called when open state changes */ + onOpenChange?: (open: boolean) => void; +} + +export function BubbleMenuNodeSelector({ + omit = [], + className, + triggerContent, + open, + onOpenChange, +}: BubbleMenuNodeSelectorProps) { + return ( + + {triggerContent} + + + ); +} diff --git a/packages/editor/src/ui/bubble-menu/root.spec.tsx b/packages/editor/src/ui/bubble-menu/root.spec.tsx new file mode 100644 index 0000000000..ab3641c170 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/root.spec.tsx @@ -0,0 +1,158 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { Editor } from '@tiptap/core'; +import { EditorContext, EditorProvider } from '@tiptap/react'; +import { StarterKit } from '../../extensions'; +import { BubbleMenuRoot } from './root'; + +vi.mock('@tiptap/react/menus', () => ({ + BubbleMenu: ({ + children, + className, + options, + ref, + }: { + children: React.ReactNode; + className?: string; + options?: { placement?: string; offset?: number; onHide?: () => void }; + ref?: React.Ref; + }) => ( +
+ {children} +
+ ), +})); + +const extensions = [StarterKit]; + +function waitForCreate() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +function createEditor() { + const element = document.createElement('div'); + document.body.append(element); + + const editor = new Editor({ + element, + extensions: [StarterKit.configure()], + content: '

Hello world

', + }); + + return { editor, element }; +} + +function renderWithEditor(ui: React.ReactElement) { + return render( + + {ui} + , + ); +} + +afterEach(() => { + cleanup(); +}); + +describe('BubbleMenuRoot', () => { + it('renders null when no editor context is available', () => { + const { container } = render( + +
child
+
, + ); + expect(container.innerHTML).toBe(''); + }); + + describe('when rendered inside EditorProvider (default bubble menu)', () => { + it('renders the default bubble menu with all sections', () => { + renderWithEditor(); + + expect(screen.getByTestId('bubble-menu-root')).toBeDefined(); + + expect(screen.getByText('Text')).toBeDefined(); + expect(screen.getByLabelText('Add link')).toBeDefined(); + + expect(screen.getByLabelText('bold')).toBeDefined(); + expect(screen.getByLabelText('italic')).toBeDefined(); + expect(screen.getByLabelText('underline')).toBeDefined(); + expect(screen.getByLabelText('strike')).toBeDefined(); + expect(screen.getByLabelText('code')).toBeDefined(); + expect(screen.getByLabelText('uppercase')).toBeDefined(); + + expect(screen.getByLabelText('align-left')).toBeDefined(); + expect(screen.getByLabelText('align-center')).toBeDefined(); + expect(screen.getByLabelText('align-right')).toBeDefined(); + }); + + it('renders two item groups', () => { + renderWithEditor(); + + expect(screen.getAllByRole('group')).toHaveLength(2); + }); + + it('renders custom children instead of the default menu', () => { + renderWithEditor( + + custom child + , + ); + + expect(screen.getByText('custom child')).toBeDefined(); + expect(screen.queryByLabelText('bold')).toBeNull(); + expect(screen.queryByLabelText('Add link')).toBeNull(); + }); + + it('keeps the editor focused when focus moves into the bubble menu', async () => { + const { editor, element } = createEditor(); + await waitForCreate(); + + const { unmount } = render( + + + custom child + + , + ); + + const root = screen.getByTestId('bubble-menu-root'); + editor.view.dom.dispatchEvent( + new FocusEvent('focusin', { bubbles: true }), + ); + editor.view.dom.dispatchEvent( + new FocusEvent('focusout', { + bubbles: true, + relatedTarget: root, + }), + ); + root.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + + expect(editor.isFocused).toBe(true); + + unmount(); + editor.destroy(); + element.remove(); + }); + + it('forwards placement and offset to the BubbleMenu', () => { + renderWithEditor(); + + const root = screen.getByTestId('bubble-menu-root'); + expect(root.dataset.placement).toBe('top'); + expect(root.dataset.offset).toBe('16'); + }); + + it('forwards className to the BubbleMenu', () => { + renderWithEditor(); + + const root = screen.getByTestId('bubble-menu-root'); + expect(root.className).toBe('custom-class'); + }); + }); +}); diff --git a/packages/editor/src/ui/bubble-menu/root.tsx b/packages/editor/src/ui/bubble-menu/root.tsx new file mode 100644 index 0000000000..38a570b40f --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/root.tsx @@ -0,0 +1,188 @@ +import { PluginKey } from '@tiptap/pm/state'; +import { useCurrentEditor, useEditorState } from '@tiptap/react'; +import { BubbleMenu } from '@tiptap/react/menus'; +import * as React from 'react'; +import { EditorFocusScope } from '../editor-focus-scope'; +import { BubbleMenuAlignCenter } from './align-center'; +import { BubbleMenuAlignLeft } from './align-left'; +import { BubbleMenuAlignRight } from './align-right'; +import { BubbleMenuBold } from './bold'; +import { BubbleMenuCode } from './code'; +import { BubbleMenuContext } from './context'; +import { BubbleMenuItemGroup } from './group'; +import { BubbleMenuItalic } from './italic'; +import { BubbleMenuLinkSelector } from './link-selector'; +import { BubbleMenuNodeSelector } from './node-selector'; +import { BubbleMenuStrike } from './strike'; +import { bubbleMenuTriggers, type TriggerFn } from './triggers'; +import { BubbleMenuUnderline } from './underline'; +import { BubbleMenuUppercase } from './uppercase'; + +const defaultPluginKey = new PluginKey('bubbleMenu'); + +export interface BubbleMenuRootProps + extends React.ComponentPropsWithoutRef<'div'> { + trigger?: TriggerFn; + pluginKey?: PluginKey; + hideWhenActiveNodes?: string[]; + hideWhenActiveMarks?: string[]; + placement?: 'top' | 'bottom'; + offset?: number; + onHide?: () => void; +} + +function Root({ + trigger, + pluginKey = defaultPluginKey, + hideWhenActiveNodes = [], + hideWhenActiveMarks = [], + placement = 'bottom', + offset = 8, + onHide, + className, + children, + ...rest +}: BubbleMenuRootProps) { + const { editor } = useCurrentEditor(); + const [isEditing, setIsEditing] = React.useState(false); + + const resolvedTrigger = + trigger ?? + bubbleMenuTriggers.textSelection(hideWhenActiveNodes, hideWhenActiveMarks); + + if (!editor) { + return null; + } + + return ( + + { + setIsEditing(false); + onHide?.(); + }, + }} + className={className} + {...rest} + > + + {children} + + + + ); +} + +const textPluginKey = new PluginKey('textBubbleMenu'); + +interface BubbleMenuDefaultProps + extends Omit, 'children'> { + hideWhenActiveNodes?: string[]; + hideWhenActiveMarks?: string[]; + placement?: 'top' | 'bottom'; + offset?: number; + onHide?: () => void; +} + +function Default({ + hideWhenActiveNodes, + hideWhenActiveMarks, + placement, + offset, + onHide, + className, + ...rest +}: BubbleMenuDefaultProps) { + const [isNodeSelectorOpen, setIsNodeSelectorOpen] = React.useState(false); + const [isLinkSelectorOpen, setIsLinkSelectorOpen] = React.useState(false); + const { editor } = useCurrentEditor(); + + const isCodeActive = useEditorState({ + editor, + selector: ({ editor: e }) => e?.isActive('code') ?? false, + }); + + const handleNodeSelectorOpenChange = React.useCallback((open: boolean) => { + setIsNodeSelectorOpen(open); + if (open) { + setIsLinkSelectorOpen(false); + } + }, []); + + const handleLinkSelectorOpenChange = React.useCallback((open: boolean) => { + setIsLinkSelectorOpen(open); + if (open) { + setIsNodeSelectorOpen(false); + } + }, []); + + const handleHide = React.useCallback(() => { + setIsNodeSelectorOpen(false); + setIsLinkSelectorOpen(false); + onHide?.(); + }, [onHide]); + + return ( + + {isCodeActive ? ( + <> + + + + ) : ( + <> + + + + + + + + + + + + + + + + + )} + + ); +} + +function RootWithDefault({ children, ...rest }: BubbleMenuRootProps) { + if (children) { + return {children}; + } + + return ; +} + +export { RootWithDefault as BubbleMenuRoot }; diff --git a/packages/editor/src/ui/bubble-menu/separator.tsx b/packages/editor/src/ui/bubble-menu/separator.tsx new file mode 100644 index 0000000000..8cf2d711f7 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/separator.tsx @@ -0,0 +1,7 @@ +export interface BubbleMenuSeparatorProps { + className?: string; +} + +export function BubbleMenuSeparator({ className }: BubbleMenuSeparatorProps) { + return
; +} diff --git a/packages/editor/src/ui/bubble-menu/strike.tsx b/packages/editor/src/ui/bubble-menu/strike.tsx new file mode 100644 index 0000000000..cafda5b972 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/strike.tsx @@ -0,0 +1,9 @@ +import { StrikethroughIcon } from '../icons'; +import { createMarkBubbleItem } from './create-mark-bubble-item'; + +export const BubbleMenuStrike = createMarkBubbleItem({ + name: 'strike', + activeName: 'strike', + command: 'toggleStrike', + icon: , +}); diff --git a/packages/editor/src/ui/bubble-menu/triggers.spec.ts b/packages/editor/src/ui/bubble-menu/triggers.spec.ts new file mode 100644 index 0000000000..0b713fddaa --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/triggers.spec.ts @@ -0,0 +1,114 @@ +import type { Editor } from '@tiptap/core'; +import type { EditorState } from '@tiptap/pm/state'; +import type { EditorView } from '@tiptap/pm/view'; +import { bubbleMenuTriggers } from './triggers'; + +function createMockParams(overrides: { + isActive?: (name: string) => boolean; + selectionSize?: number; + nodeAtDepth?: (d: number) => { type: { name: string } }; + fromDepth?: number; +}) { + const { + isActive = () => false, + selectionSize = 5, + nodeAtDepth = () => ({ type: { name: 'paragraph' } }), + fromDepth = 1, + } = overrides; + + return { + editor: { + isActive, + view: { + state: { selection: { content: () => ({ size: selectionSize }) } }, + }, + } as unknown as Editor, + view: {} as unknown as EditorView, + state: { + selection: { + $from: { depth: fromDepth, node: nodeAtDepth }, + content: () => ({ size: selectionSize }), + }, + } as unknown as EditorState, + from: 0, + to: selectionSize, + }; +} + +describe('bubbleMenuTriggers', () => { + describe('textSelection', () => { + it('shows when there is a non-empty text selection', () => { + const trigger = bubbleMenuTriggers.textSelection(); + expect(trigger(createMockParams({ selectionSize: 5 }))).toBe(true); + }); + + it('hides when selection is empty', () => { + const trigger = bubbleMenuTriggers.textSelection(); + expect(trigger(createMockParams({ selectionSize: 0 }))).toBe(false); + }); + + it('hides when an excluded node is active', () => { + const trigger = bubbleMenuTriggers.textSelection(['codeBlock']); + expect( + trigger(createMockParams({ isActive: (n) => n === 'codeBlock' })), + ).toBe(false); + }); + + it('hides when an excluded node is in ancestor chain', () => { + const trigger = bubbleMenuTriggers.textSelection(['codeBlock']); + const params = createMockParams({ + fromDepth: 2, + nodeAtDepth: (d) => ({ + type: { name: d === 2 ? 'paragraph' : 'codeBlock' }, + }), + }); + expect(trigger(params)).toBe(false); + }); + + it('hides when an excluded mark is active', () => { + const trigger = bubbleMenuTriggers.textSelection([], ['link']); + expect(trigger(createMockParams({ isActive: (n) => n === 'link' }))).toBe( + false, + ); + }); + }); + + describe('node', () => { + it('shows when the specified node is active', () => { + const trigger = bubbleMenuTriggers.node('button'); + expect( + trigger(createMockParams({ isActive: (n) => n === 'button' })), + ).toBe(true); + }); + + it('hides when the specified node is not active', () => { + const trigger = bubbleMenuTriggers.node('button'); + expect(trigger(createMockParams({}))).toBe(false); + }); + }); + + describe('nodeWithoutSelection', () => { + it('shows when node is active and selection is empty', () => { + const trigger = bubbleMenuTriggers.nodeWithoutSelection('link'); + expect( + trigger( + createMockParams({ isActive: (n) => n === 'link', selectionSize: 0 }), + ), + ).toBe(true); + }); + + it('hides when there is a text selection', () => { + const trigger = bubbleMenuTriggers.nodeWithoutSelection('link'); + expect( + trigger( + createMockParams({ isActive: (n) => n === 'link', selectionSize: 5 }), + ), + ).toBe(false); + }); + + it('hides when node is not active', () => { + const trigger = bubbleMenuTriggers.nodeWithoutSelection('link'); + expect(trigger(createMockParams({ selectionSize: 0 }))).toBe(false); + }); + }); +}); diff --git a/packages/editor/src/ui/bubble-menu/triggers.ts b/packages/editor/src/ui/bubble-menu/triggers.ts new file mode 100644 index 0000000000..e5c77ad9ed --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/triggers.ts @@ -0,0 +1,55 @@ +import type { Editor } from '@tiptap/core'; +import { type EditorState, NodeSelection } from '@tiptap/pm/state'; +import type { EditorView } from '@tiptap/pm/view'; + +export interface TriggerParams { + editor: Editor; + view: EditorView; + state: EditorState; + from: number; + to: number; +} + +export type TriggerFn = (params: TriggerParams) => boolean; + +export const bubbleMenuTriggers = { + textSelection( + hideWhenActiveNodes: string[] = [], + hideWhenActiveMarks: string[] = [], + ): TriggerFn { + return ({ editor, state }) => { + if ( + state.selection instanceof NodeSelection && + hideWhenActiveNodes.includes(state.selection.node.type.name) + ) { + return false; + } + for (const node of hideWhenActiveNodes) { + if (editor.isActive(node)) { + return false; + } + const { $from } = state.selection; + for (let d = $from.depth; d > 0; d--) { + if ($from.node(d).type.name === node) { + return false; + } + } + } + for (const mark of hideWhenActiveMarks) { + if (editor.isActive(mark)) { + return false; + } + } + return editor.view.state.selection.content().size > 0; + }; + }, + + node(name: string): TriggerFn { + return ({ editor }) => editor.isActive(name); + }, + + nodeWithoutSelection(name: string): TriggerFn { + return ({ editor }) => + editor.isActive(name) && editor.view.state.selection.content().size === 0; + }, +}; diff --git a/packages/editor/src/ui/bubble-menu/underline.tsx b/packages/editor/src/ui/bubble-menu/underline.tsx new file mode 100644 index 0000000000..47d741ad16 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/underline.tsx @@ -0,0 +1,9 @@ +import { UnderlineIcon } from '../icons'; +import { createMarkBubbleItem } from './create-mark-bubble-item'; + +export const BubbleMenuUnderline = createMarkBubbleItem({ + name: 'underline', + activeName: 'underline', + command: 'toggleUnderline', + icon: , +}); diff --git a/packages/editor/src/ui/bubble-menu/uppercase.tsx b/packages/editor/src/ui/bubble-menu/uppercase.tsx new file mode 100644 index 0000000000..85feecc4b1 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/uppercase.tsx @@ -0,0 +1,9 @@ +import { CaseUpperIcon } from '../icons'; +import { createMarkBubbleItem } from './create-mark-bubble-item'; + +export const BubbleMenuUppercase = createMarkBubbleItem({ + name: 'uppercase', + activeName: 'uppercase', + command: 'toggleUppercase', + icon: , +}); diff --git a/packages/editor/src/ui/bubble-menu/utils.spec.ts b/packages/editor/src/ui/bubble-menu/utils.spec.ts new file mode 100644 index 0000000000..bc1728fca2 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/utils.spec.ts @@ -0,0 +1,132 @@ +import { focusEditor, getUrlFromString, setLinkHref } from './utils'; + +describe('getUrlFromString', () => { + it('returns hash as-is', () => { + expect(getUrlFromString('#')).toBe('#'); + }); + + it('returns valid https URLs as-is', () => { + expect(getUrlFromString('https://example.com')).toBe('https://example.com'); + }); + + it('returns valid http URLs as-is', () => { + expect(getUrlFromString('http://example.com')).toBe('http://example.com'); + }); + + it('returns mailto URLs as-is', () => { + expect(getUrlFromString('mailto:user@example.com')).toBe( + 'mailto:user@example.com', + ); + }); + + it('returns tel URLs as-is', () => { + expect(getUrlFromString('tel:+1234567890')).toBe('tel:+1234567890'); + }); + + it('rejects javascript: URLs', () => { + expect(getUrlFromString('javascript:alert(1)')).toBeNull(); + }); + + it('rejects data: URLs', () => { + expect( + getUrlFromString('data:text/html,'), + ).toBeNull(); + }); + + it('rejects vbscript: URLs', () => { + expect(getUrlFromString('vbscript:msgbox')).toBeNull(); + }); + + it('auto-prefixes URLs with dots', () => { + const result = getUrlFromString('example.com'); + expect(result).toBe('https://example.com/'); + }); + + it('returns null for invalid strings', () => { + expect(getUrlFromString('not a url')).toBeNull(); + }); + + it('returns null for strings without dots', () => { + expect(getUrlFromString('justtext')).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(getUrlFromString('')).toBeNull(); + }); +}); + +describe('setLinkHref', () => { + function createMockEditor({ + from = 0, + to = 0, + }: { + from?: number; + to?: number; + } = {}) { + const run = vi.fn(); + const setTextSelection = vi.fn(() => ({ run })); + const setLink = vi.fn(() => ({ run, setTextSelection })); + const extendMarkRange = vi.fn(() => ({ setLink })); + const unsetLink = vi.fn(() => ({ run })); + const chain = vi.fn(() => ({ + unsetLink, + extendMarkRange, + setLink, + })); + + return { + editor: { chain, state: { selection: { from, to } } } as any, + mocks: { + chain, + unsetLink, + extendMarkRange, + setLink, + setTextSelection, + run, + }, + }; + } + + it('unsets link when href is empty', () => { + const { editor, mocks } = createMockEditor(); + setLinkHref(editor, ''); + expect(mocks.chain).toHaveBeenCalled(); + expect(mocks.unsetLink).toHaveBeenCalled(); + expect(mocks.run).toHaveBeenCalled(); + }); + + it('does not call setLink when href is empty', () => { + const { editor, mocks } = createMockEditor(); + setLinkHref(editor, ''); + expect(mocks.setLink).not.toHaveBeenCalled(); + }); + + it('uses extendMarkRange for collapsed selection', () => { + const { editor, mocks } = createMockEditor({ from: 5, to: 5 }); + setLinkHref(editor, 'https://example.com'); + expect(mocks.extendMarkRange).toHaveBeenCalledWith('link'); + expect(mocks.setLink).toHaveBeenCalledWith({ href: 'https://example.com' }); + expect(mocks.setTextSelection).toHaveBeenCalledWith({ from: 5, to: 5 }); + expect(mocks.run).toHaveBeenCalled(); + }); + + it('uses setLink directly for range selection', () => { + const { editor, mocks } = createMockEditor({ from: 2, to: 10 }); + setLinkHref(editor, 'https://example.com'); + expect(mocks.setLink).toHaveBeenCalledWith({ href: 'https://example.com' }); + expect(mocks.run).toHaveBeenCalled(); + expect(mocks.extendMarkRange).not.toHaveBeenCalled(); + }); +}); + +describe('focusEditor', () => { + it('calls editor.commands.focus() via setTimeout', () => { + vi.useFakeTimers(); + const editor = { commands: { focus: vi.fn() } } as any; + focusEditor(editor); + expect(editor.commands.focus).not.toHaveBeenCalled(); + vi.runAllTimers(); + expect(editor.commands.focus).toHaveBeenCalledOnce(); + vi.useRealTimers(); + }); +}); diff --git a/packages/editor/src/ui/bubble-menu/utils.ts b/packages/editor/src/ui/bubble-menu/utils.ts new file mode 100644 index 0000000000..3b1fb6c653 --- /dev/null +++ b/packages/editor/src/ui/bubble-menu/utils.ts @@ -0,0 +1,60 @@ +import type { Editor } from '@tiptap/core'; + +const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']); + +/** + * Basic URL validation and auto-prefixing. + * Rejects dangerous schemes (javascript:, data:, vbscript:, etc.). + * Returns the valid URL string or null. + */ +export function getUrlFromString(str: string): string | null { + if (str === '#') { + return str; + } + + try { + const url = new URL(str); + if (SAFE_PROTOCOLS.has(url.protocol)) { + return str; + } + return null; + } catch { + // not a valid URL as-is + } + + try { + if (str.includes('.') && !str.includes(' ')) { + return new URL(`https://${str}`).toString(); + } + } catch { + // still not valid + } + + return null; +} + +export function setLinkHref(editor: Editor, href: string): void { + if (href.length === 0) { + editor.chain().unsetLink().run(); + return; + } + + const { from, to } = editor.state.selection; + if (from === to) { + editor + .chain() + .extendMarkRange('link') + .setLink({ href }) + .setTextSelection({ from, to }) + .run(); + return; + } + + editor.chain().setLink({ href }).run(); +} + +export function focusEditor(editor: Editor): void { + setTimeout(() => { + editor.commands.focus(); + }, 0); +} diff --git a/packages/editor/src/ui/editor-focus-scope.spec.tsx b/packages/editor/src/ui/editor-focus-scope.spec.tsx new file mode 100644 index 0000000000..bd29793e00 --- /dev/null +++ b/packages/editor/src/ui/editor-focus-scope.spec.tsx @@ -0,0 +1,259 @@ +import { render, screen } from '@testing-library/react'; +import { Editor } from '@tiptap/core'; +import { EditorContext } from '@tiptap/react'; +import TipTapStarterKit from '@tiptap/starter-kit'; +import * as React from 'react'; +import { + EditorFocusScope, + EditorFocusScopeProvider, + useEditorFocusScope, +} from './editor-focus-scope'; + +function waitForCreate() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +const ManuallyForwardedRefChild = React.forwardRef( + function ManuallyForwardedRefChild(_, forwardedRef) { + const elementRef = React.useRef(null); + + React.useLayoutEffect(() => { + if (typeof forwardedRef !== 'function') return; + + forwardedRef(elementRef.current); + return () => { + forwardedRef(null); + }; + }, [forwardedRef]); + + return
Manual ref child
; + }, +); + +describe('EditorFocusScope', () => { + it('renders children without a provider', () => { + render( + + + , + ); + + expect(screen.getByRole('button', { name: 'Scoped action' })).toBeDefined(); + }); + + it('registers and unregisters children with extension storage', () => { + const registerScope = vi.fn(); + const unregisterScope = vi.fn(); + const editor = { + extensionStorage: { + focusScope: { + registerScope, + unregisterScope, + }, + }, + }; + + const { unmount } = render( + + + + + , + ); + + const button = screen.getByRole('button', { name: 'Scoped action' }); + expect(registerScope).toHaveBeenCalledWith(button); + + unmount(); + expect(unregisterScope).toHaveBeenCalledWith(button); + }); + + it('unregisters the previous child when the scoped element changes', () => { + const registerScope = vi.fn(); + const unregisterScope = vi.fn(); + const editor = { + extensionStorage: { + focusScope: { + registerScope, + unregisterScope, + }, + }, + }; + + const { rerender } = render( + + + + + , + ); + + const firstButton = screen.getByRole('button', { name: 'First action' }); + expect(registerScope).toHaveBeenCalledWith(firstButton); + + rerender( + + + Second action + + , + ); + + const secondLink = screen.getByRole('link', { name: 'Second action' }); + expect(unregisterScope).toHaveBeenCalledWith(firstButton); + expect(registerScope).toHaveBeenCalledWith(secondLink); + }); + + it('unregisters from the old focus scope when extension storage changes', () => { + const firstRegisterScope = vi.fn(); + const firstUnregisterScope = vi.fn(); + const secondRegisterScope = vi.fn(); + const secondUnregisterScope = vi.fn(); + const editor = { + extensionStorage: { + focusScope: { + registerScope: firstRegisterScope, + unregisterScope: firstUnregisterScope, + }, + }, + }; + + const { rerender, unmount } = render( + + + + + , + ); + + const button = screen.getByRole('button', { name: 'Scoped action' }); + expect(firstRegisterScope).toHaveBeenCalledWith(button); + + editor.extensionStorage.focusScope = { + registerScope: secondRegisterScope, + unregisterScope: secondUnregisterScope, + }; + + rerender( + + + + + , + ); + + expect(firstUnregisterScope).toHaveBeenCalledWith(button); + expect(secondRegisterScope).toHaveBeenCalledWith(button); + + unmount(); + expect(secondUnregisterScope).toHaveBeenCalledWith(button); + }); + + it('unregisters when a forwarded child manually clears the ref', () => { + const registerScope = vi.fn(); + const unregisterScope = vi.fn(); + const editor = { + extensionStorage: { + focusScope: { + registerScope, + unregisterScope, + }, + }, + }; + + const { unmount } = render( + + + + + , + ); + + const child = screen.getByText('Manual ref child'); + expect(registerScope).toHaveBeenCalledWith(child); + + unmount(); + expect(unregisterScope).toHaveBeenCalledWith(child); + }); +}); + +describe('EditorFocusScopeProvider', () => { + it('renders children', () => { + render( + + + , + ); + + expect( + screen.getByRole('button', { name: 'Inside provider' }), + ).toBeDefined(); + }); + + it('installs focus scope tracking when the editor does not use StarterKit', async () => { + const element = document.createElement('div'); + document.body.append(element); + const editor = new Editor({ + element, + extensions: [TipTapStarterKit], + content: '

Hello world

', + }); + await waitForCreate(); + + render( + + + + + + + , + ); + + const button = screen.getByRole('button', { name: 'Scoped action' }); + editor.view.dom.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + editor.view.dom.dispatchEvent( + new FocusEvent('focusout', { + bubbles: true, + relatedTarget: button, + }), + ); + button.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + + expect(editor.isFocused).toBe(true); + + editor.destroy(); + element.remove(); + }); +}); + +describe('useEditorFocusScope', () => { + it('returns the extension storage registration functions', () => { + const registerScope = vi.fn(); + const unregisterScope = vi.fn(); + const editor = { + extensionStorage: { + focusScope: { + registerScope, + unregisterScope, + }, + }, + }; + + function Probe() { + const focusScope = useEditorFocusScope(); + focusScope.registerScope(null); + focusScope.unregisterScope(null); + return null; + } + + render( + + + , + ); + + expect(registerScope).toHaveBeenCalledWith(null); + expect(unregisterScope).toHaveBeenCalledWith(null); + }); +}); diff --git a/packages/editor/src/ui/editor-focus-scope.tsx b/packages/editor/src/ui/editor-focus-scope.tsx new file mode 100644 index 0000000000..6a86a3fc44 --- /dev/null +++ b/packages/editor/src/ui/editor-focus-scope.tsx @@ -0,0 +1,130 @@ +import { Slot } from '@radix-ui/react-slot'; +import { extensions as nativeTiptapExtensions } from '@tiptap/core'; +import { useCurrentEditor } from '@tiptap/react'; +import * as React from 'react'; +import { + createFocusScopePlugin, + createFocusScopesStorage, + type FocusScopesStorage, + focusScopePluginKey, +} from '../extensions/focus-scopes'; + +type FocusScopeContextValue = FocusScopesStorage; + +export const FocusScopeContext = + React.createContext(null); + +const noopFocusScope: FocusScopeContextValue = { + registerScope: () => {}, + unregisterScope: () => {}, +}; + +export function useEditorFocusScope() { + const context = React.useContext(FocusScopeContext); + const { editor } = useCurrentEditor(); + return context ?? editor?.extensionStorage?.focusScope ?? noopFocusScope; +} + +export interface EditorFocusScopeProviderProps { + children: React.ReactNode; + clearSelectionOnBlur?: boolean; +} + +/** + * @deprecated Focus scope tracking now lives in the FocusScopes extension, + * included by default through StarterKit. This component is kept as a + * compatibility wrapper for editors that do not use StarterKit. + */ +export function EditorFocusScopeProvider({ + children, + clearSelectionOnBlur = true, +}: EditorFocusScopeProviderProps) { + const { editor } = useCurrentEditor(); + const [fallbackFocusScope, setFallbackFocusScope] = + React.useState(null); + + React.useLayoutEffect(() => { + if (!editor) return; + + const hasFocusScopePlugin = editor.state.plugins.some( + (plugin) => plugin.spec.key === focusScopePluginKey, + ); + if (hasFocusScopePlugin) { + setFallbackFocusScope(editor.extensionStorage.focusScope ?? null); + return; + } + + const defaultFocusPlugin = editor.state.plugins.find( + (plugin) => + plugin.spec.key === nativeTiptapExtensions.focusEventsPluginKey, + ); + if (defaultFocusPlugin) { + editor.unregisterPlugin(nativeTiptapExtensions.focusEventsPluginKey); + } + + const storage = + editor.extensionStorage.focusScope ?? createFocusScopesStorage(); + editor.extensionStorage.focusScope = storage; + editor.registerPlugin( + createFocusScopePlugin({ + editor, + storage, + clearSelectionOnBlur, + }), + ); + setFallbackFocusScope(storage); + + return () => { + editor.unregisterPlugin(focusScopePluginKey); + if (!editor.isDestroyed && defaultFocusPlugin) { + editor.registerPlugin(defaultFocusPlugin); + } + }; + }, [editor, clearSelectionOnBlur]); + + const focusScope = + fallbackFocusScope ?? + editor?.extensionStorage?.focusScope ?? + noopFocusScope; + + return ( + + {children} + + ); +} + +export interface EditorFocusScopeProps { + children: React.ReactNode; +} + +export function EditorFocusScope({ children }: EditorFocusScopeProps) { + const context = React.useContext(FocusScopeContext); + const { editor } = useCurrentEditor(); + const focusScope = context ?? editor?.extensionStorage?.focusScope ?? null; + const attachedElRef = React.useRef(null); + + const setScopeRef = React.useCallback( + (element: HTMLElement | null) => { + if (!focusScope) return; + + const prev = attachedElRef.current; + if (prev && prev !== element) { + focusScope.unregisterScope(prev); + } + + attachedElRef.current = element; + + if (element) { + focusScope.registerScope(element); + } + }, + [focusScope], + ); + + if (!focusScope) { + return <>{children}; + } + + return {children}; +} diff --git a/packages/editor/src/ui/icons/align-center-vertical.tsx b/packages/editor/src/ui/icons/align-center-vertical.tsx new file mode 100644 index 0000000000..57012e7b20 --- /dev/null +++ b/packages/editor/src/ui/icons/align-center-vertical.tsx @@ -0,0 +1,30 @@ +import type { IconProps } from './types'; + +export function AlignCenterVerticalIcon({ + size, + width, + height, + ...props +}: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/align-center.tsx b/packages/editor/src/ui/icons/align-center.tsx new file mode 100644 index 0000000000..974a17b6d0 --- /dev/null +++ b/packages/editor/src/ui/icons/align-center.tsx @@ -0,0 +1,23 @@ +import type { IconProps } from './types'; + +export function AlignCenterIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/align-end-vertical.tsx b/packages/editor/src/ui/icons/align-end-vertical.tsx new file mode 100644 index 0000000000..1834803616 --- /dev/null +++ b/packages/editor/src/ui/icons/align-end-vertical.tsx @@ -0,0 +1,28 @@ +import type { IconProps } from './types'; + +export function AlignEndVerticalIcon({ + size, + width, + height, + ...props +}: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/align-left.tsx b/packages/editor/src/ui/icons/align-left.tsx new file mode 100644 index 0000000000..7a4120f719 --- /dev/null +++ b/packages/editor/src/ui/icons/align-left.tsx @@ -0,0 +1,23 @@ +import type { IconProps } from './types'; + +export function AlignLeftIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/align-right.tsx b/packages/editor/src/ui/icons/align-right.tsx new file mode 100644 index 0000000000..3e5e5fb2a0 --- /dev/null +++ b/packages/editor/src/ui/icons/align-right.tsx @@ -0,0 +1,23 @@ +import type { IconProps } from './types'; + +export function AlignRightIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/align-start-vertical.tsx b/packages/editor/src/ui/icons/align-start-vertical.tsx new file mode 100644 index 0000000000..adee58f841 --- /dev/null +++ b/packages/editor/src/ui/icons/align-start-vertical.tsx @@ -0,0 +1,28 @@ +import type { IconProps } from './types'; + +export function AlignStartVerticalIcon({ + size, + width, + height, + ...props +}: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/bold.tsx b/packages/editor/src/ui/icons/bold.tsx new file mode 100644 index 0000000000..a4b1f371af --- /dev/null +++ b/packages/editor/src/ui/icons/bold.tsx @@ -0,0 +1,21 @@ +import type { IconProps } from './types'; + +export function BoldIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/box.tsx b/packages/editor/src/ui/icons/box.tsx new file mode 100644 index 0000000000..744a9de1ad --- /dev/null +++ b/packages/editor/src/ui/icons/box.tsx @@ -0,0 +1,23 @@ +import type { IconProps } from './types'; + +export function BoxIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/case-upper.tsx b/packages/editor/src/ui/icons/case-upper.tsx new file mode 100644 index 0000000000..3deab79b30 --- /dev/null +++ b/packages/editor/src/ui/icons/case-upper.tsx @@ -0,0 +1,23 @@ +import type { IconProps } from './types'; + +export function CaseUpperIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/check.tsx b/packages/editor/src/ui/icons/check.tsx new file mode 100644 index 0000000000..d05b56576e --- /dev/null +++ b/packages/editor/src/ui/icons/check.tsx @@ -0,0 +1,21 @@ +import type { IconProps } from './types'; + +export function CheckIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/chevron-down.tsx b/packages/editor/src/ui/icons/chevron-down.tsx new file mode 100644 index 0000000000..7af02240b9 --- /dev/null +++ b/packages/editor/src/ui/icons/chevron-down.tsx @@ -0,0 +1,21 @@ +import type { IconProps } from './types'; + +export function ChevronDownIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/code.tsx b/packages/editor/src/ui/icons/code.tsx new file mode 100644 index 0000000000..3c5ca7cbe3 --- /dev/null +++ b/packages/editor/src/ui/icons/code.tsx @@ -0,0 +1,22 @@ +import type { IconProps } from './types'; + +export function CodeIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/columns-2.tsx b/packages/editor/src/ui/icons/columns-2.tsx new file mode 100644 index 0000000000..a4ca398d53 --- /dev/null +++ b/packages/editor/src/ui/icons/columns-2.tsx @@ -0,0 +1,22 @@ +import type { IconProps } from './types'; + +export function Columns2Icon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/columns-3.tsx b/packages/editor/src/ui/icons/columns-3.tsx new file mode 100644 index 0000000000..5cca815474 --- /dev/null +++ b/packages/editor/src/ui/icons/columns-3.tsx @@ -0,0 +1,23 @@ +import type { IconProps } from './types'; + +export function Columns3Icon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/columns-4.tsx b/packages/editor/src/ui/icons/columns-4.tsx new file mode 100644 index 0000000000..cb0005b179 --- /dev/null +++ b/packages/editor/src/ui/icons/columns-4.tsx @@ -0,0 +1,24 @@ +import type { IconProps } from './types'; + +export function Columns4Icon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/corner-bottom-left.tsx b/packages/editor/src/ui/icons/corner-bottom-left.tsx new file mode 100644 index 0000000000..0235e48256 --- /dev/null +++ b/packages/editor/src/ui/icons/corner-bottom-left.tsx @@ -0,0 +1,27 @@ +import type { IconProps } from './types'; + +export function CornerBottomLeftIcon({ + size, + width, + height, + ...props +}: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/corner-bottom-right.tsx b/packages/editor/src/ui/icons/corner-bottom-right.tsx new file mode 100644 index 0000000000..292129a023 --- /dev/null +++ b/packages/editor/src/ui/icons/corner-bottom-right.tsx @@ -0,0 +1,27 @@ +import type { IconProps } from './types'; + +export function CornerBottomRightIcon({ + size, + width, + height, + ...props +}: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/corner-top-left.tsx b/packages/editor/src/ui/icons/corner-top-left.tsx new file mode 100644 index 0000000000..4d25e341fe --- /dev/null +++ b/packages/editor/src/ui/icons/corner-top-left.tsx @@ -0,0 +1,27 @@ +import type { IconProps } from './types'; + +export function CornerTopLeftIcon({ + size, + width, + height, + ...props +}: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/corner-top-right.tsx b/packages/editor/src/ui/icons/corner-top-right.tsx new file mode 100644 index 0000000000..5426092a76 --- /dev/null +++ b/packages/editor/src/ui/icons/corner-top-right.tsx @@ -0,0 +1,27 @@ +import type { IconProps } from './types'; + +export function CornerTopRightIcon({ + size, + width, + height, + ...props +}: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/external-link.tsx b/packages/editor/src/ui/icons/external-link.tsx new file mode 100644 index 0000000000..7d9ebddde9 --- /dev/null +++ b/packages/editor/src/ui/icons/external-link.tsx @@ -0,0 +1,23 @@ +import type { IconProps } from './types'; + +export function ExternalLinkIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/heading-1.tsx b/packages/editor/src/ui/icons/heading-1.tsx new file mode 100644 index 0000000000..ff59cc1353 --- /dev/null +++ b/packages/editor/src/ui/icons/heading-1.tsx @@ -0,0 +1,24 @@ +import type { IconProps } from './types'; + +export function Heading1Icon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/heading-2.tsx b/packages/editor/src/ui/icons/heading-2.tsx new file mode 100644 index 0000000000..e73c8db760 --- /dev/null +++ b/packages/editor/src/ui/icons/heading-2.tsx @@ -0,0 +1,24 @@ +import type { IconProps } from './types'; + +export function Heading2Icon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/heading-3.tsx b/packages/editor/src/ui/icons/heading-3.tsx new file mode 100644 index 0000000000..b8c37d0141 --- /dev/null +++ b/packages/editor/src/ui/icons/heading-3.tsx @@ -0,0 +1,25 @@ +import type { IconProps } from './types'; + +export function Heading3Icon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/image.tsx b/packages/editor/src/ui/icons/image.tsx new file mode 100644 index 0000000000..9fa97478d9 --- /dev/null +++ b/packages/editor/src/ui/icons/image.tsx @@ -0,0 +1,23 @@ +import type { IconProps } from './types'; + +export function ImageIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/index.ts b/packages/editor/src/ui/icons/index.ts new file mode 100644 index 0000000000..69a6a461a4 --- /dev/null +++ b/packages/editor/src/ui/icons/index.ts @@ -0,0 +1,53 @@ +export { AlignCenterIcon } from './align-center'; +export { AlignCenterVerticalIcon } from './align-center-vertical'; +export { AlignEndVerticalIcon } from './align-end-vertical'; +export { AlignLeftIcon } from './align-left'; +export { AlignRightIcon } from './align-right'; +export { AlignStartVerticalIcon } from './align-start-vertical'; +export { BoldIcon } from './bold'; +export { BoxIcon } from './box'; +export { CaseUpperIcon } from './case-upper'; +export { CheckIcon } from './check'; +export { ChevronDownIcon } from './chevron-down'; +export { CodeIcon } from './code'; +export { Columns2Icon } from './columns-2'; +export { Columns3Icon } from './columns-3'; +export { Columns4Icon } from './columns-4'; +export { CornerBottomLeftIcon } from './corner-bottom-left'; +export { CornerBottomRightIcon } from './corner-bottom-right'; +export { CornerTopLeftIcon } from './corner-top-left'; +export { CornerTopRightIcon } from './corner-top-right'; +export { ExternalLinkIcon } from './external-link'; +export { Heading1Icon } from './heading-1'; +export { Heading2Icon } from './heading-2'; +export { Heading3Icon } from './heading-3'; +export { ImageIcon } from './image'; +export { ItalicIcon } from './italic'; +export { LayoutIcon } from './layout'; +export { LinkIcon } from './link'; +export { ListIcon } from './list'; +export { ListOrderedIcon } from './list-ordered'; +export { MinusIcon } from './minus'; +export { MousePointerIcon } from './mouse-pointer'; +export { MousePointerClickIcon } from './mouse-pointer-click'; +export { PanelBottomIcon } from './panel-bottom'; +export { PanelLeftIcon } from './panel-left'; +export { PanelRightIcon } from './panel-right'; +export { PanelTopIcon } from './panel-top'; +export { PencilIcon } from './pencil'; +export { PlusIcon } from './plus'; +export { Rows2Icon } from './rows-2'; +export { SplitSquareVerticalIcon } from './split-square-vertical'; +export { SquareIcon } from './square'; +export { SquareCodeIcon } from './square-code'; +export { SquareDashedIcon } from './square-dashed'; +export { SquareRoundCornerIcon } from './square-round-corner'; +export { StrikethroughIcon } from './strikethrough'; +export { TableIcon } from './table'; +export { TextIcon } from './text'; +export { TextQuoteIcon } from './text-quote'; +export { TypeIcon } from './type'; +export type { IconProps } from './types'; +export { UnderlineIcon } from './underline'; +export { UnlinkIcon } from './unlink'; +export { XIcon } from './x'; diff --git a/packages/editor/src/ui/icons/italic.tsx b/packages/editor/src/ui/icons/italic.tsx new file mode 100644 index 0000000000..dcdad5994f --- /dev/null +++ b/packages/editor/src/ui/icons/italic.tsx @@ -0,0 +1,23 @@ +import type { IconProps } from './types'; + +export function ItalicIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/layout.tsx b/packages/editor/src/ui/icons/layout.tsx new file mode 100644 index 0000000000..172b64f12c --- /dev/null +++ b/packages/editor/src/ui/icons/layout.tsx @@ -0,0 +1,23 @@ +import type { IconProps } from './types'; + +export function LayoutIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/link.tsx b/packages/editor/src/ui/icons/link.tsx new file mode 100644 index 0000000000..c401eeb802 --- /dev/null +++ b/packages/editor/src/ui/icons/link.tsx @@ -0,0 +1,22 @@ +import type { IconProps } from './types'; + +export function LinkIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/list-ordered.tsx b/packages/editor/src/ui/icons/list-ordered.tsx new file mode 100644 index 0000000000..7b79fec23a --- /dev/null +++ b/packages/editor/src/ui/icons/list-ordered.tsx @@ -0,0 +1,26 @@ +import type { IconProps } from './types'; + +export function ListOrderedIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/list.tsx b/packages/editor/src/ui/icons/list.tsx new file mode 100644 index 0000000000..0a2b48b5f1 --- /dev/null +++ b/packages/editor/src/ui/icons/list.tsx @@ -0,0 +1,26 @@ +import type { IconProps } from './types'; + +export function ListIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/minus.tsx b/packages/editor/src/ui/icons/minus.tsx new file mode 100644 index 0000000000..0b6d93cf5c --- /dev/null +++ b/packages/editor/src/ui/icons/minus.tsx @@ -0,0 +1,21 @@ +import type { IconProps } from './types'; + +export function MinusIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/mouse-pointer-click.tsx b/packages/editor/src/ui/icons/mouse-pointer-click.tsx new file mode 100644 index 0000000000..45477e13af --- /dev/null +++ b/packages/editor/src/ui/icons/mouse-pointer-click.tsx @@ -0,0 +1,30 @@ +import type { IconProps } from './types'; + +export function MousePointerClickIcon({ + size, + width, + height, + ...props +}: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/mouse-pointer.tsx b/packages/editor/src/ui/icons/mouse-pointer.tsx new file mode 100644 index 0000000000..3d280149d8 --- /dev/null +++ b/packages/editor/src/ui/icons/mouse-pointer.tsx @@ -0,0 +1,22 @@ +import type { IconProps } from './types'; + +export function MousePointerIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/panel-bottom.tsx b/packages/editor/src/ui/icons/panel-bottom.tsx new file mode 100644 index 0000000000..9c980f19e9 --- /dev/null +++ b/packages/editor/src/ui/icons/panel-bottom.tsx @@ -0,0 +1,22 @@ +import type { IconProps } from './types'; + +export function PanelBottomIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/panel-left.tsx b/packages/editor/src/ui/icons/panel-left.tsx new file mode 100644 index 0000000000..363fff45df --- /dev/null +++ b/packages/editor/src/ui/icons/panel-left.tsx @@ -0,0 +1,22 @@ +import type { IconProps } from './types'; + +export function PanelLeftIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/panel-right.tsx b/packages/editor/src/ui/icons/panel-right.tsx new file mode 100644 index 0000000000..8ec2799e12 --- /dev/null +++ b/packages/editor/src/ui/icons/panel-right.tsx @@ -0,0 +1,22 @@ +import type { IconProps } from './types'; + +export function PanelRightIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/panel-top.tsx b/packages/editor/src/ui/icons/panel-top.tsx new file mode 100644 index 0000000000..b86fafde2a --- /dev/null +++ b/packages/editor/src/ui/icons/panel-top.tsx @@ -0,0 +1,22 @@ +import type { IconProps } from './types'; + +export function PanelTopIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/pencil.tsx b/packages/editor/src/ui/icons/pencil.tsx new file mode 100644 index 0000000000..8647d5b6e0 --- /dev/null +++ b/packages/editor/src/ui/icons/pencil.tsx @@ -0,0 +1,22 @@ +import type { IconProps } from './types'; + +export function PencilIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/plus.tsx b/packages/editor/src/ui/icons/plus.tsx new file mode 100644 index 0000000000..9015d9a2dd --- /dev/null +++ b/packages/editor/src/ui/icons/plus.tsx @@ -0,0 +1,22 @@ +import type { IconProps } from './types'; + +export function PlusIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/rows-2.tsx b/packages/editor/src/ui/icons/rows-2.tsx new file mode 100644 index 0000000000..4bb819a795 --- /dev/null +++ b/packages/editor/src/ui/icons/rows-2.tsx @@ -0,0 +1,22 @@ +import type { IconProps } from './types'; + +export function Rows2Icon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/split-square-vertical.tsx b/packages/editor/src/ui/icons/split-square-vertical.tsx new file mode 100644 index 0000000000..5e17eb2f2f --- /dev/null +++ b/packages/editor/src/ui/icons/split-square-vertical.tsx @@ -0,0 +1,28 @@ +import type { IconProps } from './types'; + +export function SplitSquareVerticalIcon({ + size, + width, + height, + ...props +}: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/square-code.tsx b/packages/editor/src/ui/icons/square-code.tsx new file mode 100644 index 0000000000..d78e316a74 --- /dev/null +++ b/packages/editor/src/ui/icons/square-code.tsx @@ -0,0 +1,23 @@ +import type { IconProps } from './types'; + +export function SquareCodeIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/square-dashed.tsx b/packages/editor/src/ui/icons/square-dashed.tsx new file mode 100644 index 0000000000..e628591cd3 --- /dev/null +++ b/packages/editor/src/ui/icons/square-dashed.tsx @@ -0,0 +1,32 @@ +import type { IconProps } from './types'; + +export function SquareDashedIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/square-round-corner.tsx b/packages/editor/src/ui/icons/square-round-corner.tsx new file mode 100644 index 0000000000..10500ea77a --- /dev/null +++ b/packages/editor/src/ui/icons/square-round-corner.tsx @@ -0,0 +1,27 @@ +import type { IconProps } from './types'; + +export function SquareRoundCornerIcon({ + size, + width, + height, + ...props +}: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/square.tsx b/packages/editor/src/ui/icons/square.tsx new file mode 100644 index 0000000000..84cc2b8409 --- /dev/null +++ b/packages/editor/src/ui/icons/square.tsx @@ -0,0 +1,21 @@ +import type { IconProps } from './types'; + +export function SquareIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/strikethrough.tsx b/packages/editor/src/ui/icons/strikethrough.tsx new file mode 100644 index 0000000000..9cf2f4c1c3 --- /dev/null +++ b/packages/editor/src/ui/icons/strikethrough.tsx @@ -0,0 +1,28 @@ +import type { IconProps } from './types'; + +export function StrikethroughIcon({ + size, + width, + height, + ...props +}: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/table.tsx b/packages/editor/src/ui/icons/table.tsx new file mode 100644 index 0000000000..b59c8106ae --- /dev/null +++ b/packages/editor/src/ui/icons/table.tsx @@ -0,0 +1,24 @@ +import type { IconProps } from './types'; + +export function TableIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/text-quote.tsx b/packages/editor/src/ui/icons/text-quote.tsx new file mode 100644 index 0000000000..ad19ce6738 --- /dev/null +++ b/packages/editor/src/ui/icons/text-quote.tsx @@ -0,0 +1,24 @@ +import type { IconProps } from './types'; + +export function TextQuoteIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/text.tsx b/packages/editor/src/ui/icons/text.tsx new file mode 100644 index 0000000000..4e83ffbb31 --- /dev/null +++ b/packages/editor/src/ui/icons/text.tsx @@ -0,0 +1,23 @@ +import type { IconProps } from './types'; + +export function TextIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/type.tsx b/packages/editor/src/ui/icons/type.tsx new file mode 100644 index 0000000000..a752e197fe --- /dev/null +++ b/packages/editor/src/ui/icons/type.tsx @@ -0,0 +1,23 @@ +import type { IconProps } from './types'; + +export function TypeIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/types.ts b/packages/editor/src/ui/icons/types.ts new file mode 100644 index 0000000000..f758b1781c --- /dev/null +++ b/packages/editor/src/ui/icons/types.ts @@ -0,0 +1,5 @@ +import type * as React from 'react'; + +export interface IconProps extends React.SVGAttributes { + size?: number | string; +} diff --git a/packages/editor/src/ui/icons/underline.tsx b/packages/editor/src/ui/icons/underline.tsx new file mode 100644 index 0000000000..6fd505405d --- /dev/null +++ b/packages/editor/src/ui/icons/underline.tsx @@ -0,0 +1,22 @@ +import type { IconProps } from './types'; + +export function UnderlineIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/unlink.tsx b/packages/editor/src/ui/icons/unlink.tsx new file mode 100644 index 0000000000..13ca4edbe4 --- /dev/null +++ b/packages/editor/src/ui/icons/unlink.tsx @@ -0,0 +1,26 @@ +import type { IconProps } from './types'; + +export function UnlinkIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/icons/x.tsx b/packages/editor/src/ui/icons/x.tsx new file mode 100644 index 0000000000..934834da86 --- /dev/null +++ b/packages/editor/src/ui/icons/x.tsx @@ -0,0 +1,22 @@ +import type { IconProps } from './types'; + +export function XIcon({ size, width, height, ...props }: IconProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/index.ts b/packages/editor/src/ui/index.ts new file mode 100644 index 0000000000..777cd9e18c --- /dev/null +++ b/packages/editor/src/ui/index.ts @@ -0,0 +1,5 @@ +export * from './bubble-menu'; +export * from './editor-focus-scope'; +export * from './icons'; +export * from './inspector'; +export * from './slash-command'; diff --git a/packages/editor/src/ui/inspector/breadcrumb.tsx b/packages/editor/src/ui/inspector/breadcrumb.tsx new file mode 100644 index 0000000000..bf64080675 --- /dev/null +++ b/packages/editor/src/ui/inspector/breadcrumb.tsx @@ -0,0 +1,110 @@ +import { useCurrentEditor } from '@tiptap/react'; +import React from 'react'; +import { getNodeMeta } from './config/node-meta'; +import { type FocusedNode, useInspector } from './root'; + +export interface InspectorBreadcrumbSegment { + node: FocusedNode; + focus: () => void; +} + +export interface InspectorBreadcrumbProps { + children?: (segments: InspectorBreadcrumbSegment[]) => React.ReactNode; +} + +export function InspectorBreadcrumb({ children }: InspectorBreadcrumbProps) { + const { editor } = useCurrentEditor(); + const { pathFromRoot } = useInspector(); + + const segments = React.useMemo(() => { + if (!editor || pathFromRoot.length === 0) { + return []; + } + return pathFromRoot.map((focusedNode) => ({ + node: focusedNode, + focus() { + if (focusedNode.nodeType === 'body') { + // Body is a logical root, not always a concrete ProseMirror node — + // blur to surface the document-level inspector rather than risk + // selecting whatever is at pos 0. + if (typeof document !== 'undefined') { + const active = document.activeElement; + if (active instanceof HTMLElement) { + active.blur(); + } + } + editor.commands.blur(); + return; + } + editor.commands.setNodeSelection(focusedNode.nodePos.pos); + editor.commands.focus(); + }, + })) satisfies InspectorBreadcrumbSegment[]; + }, [editor, pathFromRoot]); + + if (children) { + return children(segments); + } + + return ; +} + +const MAX_VISIBLE = 3; + +function getVisibleSegments(segments: InspectorBreadcrumbSegment[]) { + if (segments.length <= MAX_VISIBLE) { + return { + items: segments.map((s, i) => ({ segment: s, index: i })), + hasEllipsis: false, + }; + } + + const first = { segment: segments[0], index: 0 }; + const last = segments.slice(-2).map((s, i) => ({ + segment: s, + index: segments.length - 2 + i, + })); + + return { items: [first, ...last], hasEllipsis: true }; +} + +function BreadcrumbDefault({ + segments, +}: { + segments: InspectorBreadcrumbSegment[]; +}) { + const { items, hasEllipsis } = getVisibleSegments(segments); + + return ( + + ); +} diff --git a/packages/editor/src/ui/inspector/components/border-picker.tsx b/packages/editor/src/ui/inspector/components/border-picker.tsx new file mode 100644 index 0000000000..561966e803 --- /dev/null +++ b/packages/editor/src/ui/inspector/components/border-picker.tsx @@ -0,0 +1,301 @@ +'use client'; + +import * as React from 'react'; +import { SUPPORTED_CSS_PROPERTIES } from '../../../plugins/email-theming/themes'; +import { + PanelBottomIcon, + PanelLeftIcon, + PanelRightIcon, + PanelTopIcon, + SquareDashedIcon, + SquareIcon, + XIcon, +} from '../../icons'; +import { useDragToChange } from '../hooks/use-drag-to-change'; +import { useNumericInput } from '../hooks/use-numeric-input'; +import { + ColorInput, + IconButton, + Label, + Select, + TextField, + ToggleGroup, + Tooltip, +} from '../primitives'; +import { PropRow } from './prop-row'; + +export type BatchableChangeFn = ( + propOrChanges: string | [string, string | number][], + value?: string | number, +) => void; + +type Side = 'Top' | 'Right' | 'Bottom' | 'Left'; + +const SIDES: { side: Side; icon: React.ReactNode }[] = [ + { side: 'Top', icon: }, + { side: 'Right', icon: }, + { side: 'Bottom', icon: }, + { side: 'Left', icon: }, +]; + +interface BorderPickerProps { + styleObject: Record; + onChange: BatchableChangeFn; +} + +function getSideValue( + styleObject: Record, + side: Side, + prop: 'Width' | 'Color' | 'Style', +): string | number | undefined { + const sideKey = `border${side}${prop}`; + const shorthandKey = `border${prop}`; + return styleObject[sideKey] ?? styleObject[shorthandKey]; +} + +function allSidesEqual( + styleObject: Record, +): boolean { + const topWidth = getSideValue(styleObject, 'Top', 'Width'); + const topColor = getSideValue(styleObject, 'Top', 'Color'); + const topStyle = getSideValue(styleObject, 'Top', 'Style'); + + return SIDES.every(({ side }) => { + return ( + getSideValue(styleObject, side, 'Width') === topWidth && + getSideValue(styleObject, side, 'Color') === topColor && + getSideValue(styleObject, side, 'Style') === topStyle + ); + }); +} + +const BORDER_STYLE_OPTIONS = SUPPORTED_CSS_PROPERTIES.borderStyle + .options as Record; + +export function BorderPicker({ styleObject, onChange }: BorderPickerProps) { + const [expanded, setExpanded] = React.useState( + () => !allSidesEqual(styleObject), + ); + + const handleModeChange = (mode: string) => { + if (mode === 'uniform' && expanded) { + const width = + getSideValue(styleObject, 'Top', 'Width') ?? + String(SUPPORTED_CSS_PROPERTIES.borderWidth.defaultValue); + const color = + getSideValue(styleObject, 'Top', 'Color') ?? + String(SUPPORTED_CSS_PROPERTIES.borderColor.defaultValue); + const style = + getSideValue(styleObject, 'Top', 'Style') ?? + String(SUPPORTED_CSS_PROPERTIES.borderStyle.defaultValue); + + const changes: [string, string | number][] = [ + ['borderWidth', width], + ['borderColor', color], + ['borderStyle', style], + ]; + for (const { side } of SIDES) { + changes.push( + [`border${side}Width`, ''], + [`border${side}Color`, ''], + [`border${side}Style`, ''], + ); + } + + onChange(changes); + setExpanded(false); + } else if (mode === 'individual' && !expanded) { + const width = + styleObject.borderWidth ?? + String(SUPPORTED_CSS_PROPERTIES.borderWidth.defaultValue); + const color = + styleObject.borderColor ?? + String(SUPPORTED_CSS_PROPERTIES.borderColor.defaultValue); + const style = + styleObject.borderStyle ?? + String(SUPPORTED_CSS_PROPERTIES.borderStyle.defaultValue); + + const changes: [string, string | number][] = []; + for (const { side } of SIDES) { + changes.push( + [`border${side}Width`, styleObject[`border${side}Width`] ?? width], + [`border${side}Color`, styleObject[`border${side}Color`] ?? color], + [`border${side}Style`, styleObject[`border${side}Style`] ?? style], + ); + } + changes.push( + ['borderWidth', ''], + ['borderColor', ''], + ['borderStyle', ''], + ); + + onChange(changes); + setExpanded(true); + } + }; + + const modeToggle = ( + + + + + + + + Uniform + + + + + + + + Per side + + + ); + + if (expanded) { + return ( +
+ + + {modeToggle} + + +
+ {SIDES.map(({ side, icon }) => ( +
+
+ onChange(`border${side}Width`, v)} + className="w-full" + /> + + onChange(`border${side}Color`, v)} + className="w-full" + /> +
+ +
+ + onChange(`border${side}Style`, e.target.value) + } + > + {Object.entries(BORDER_STYLE_OPTIONS).map(([val, label]) => ( + + {label} + + ))} + + + { + onChange([ + [`border${side}Width`, ''], + [`border${side}Style`, ''], + [`border${side}Color`, ''], + ]); + }} + aria-label={`Clear ${side.toLowerCase()} border`} + > + + +
+
+ ))} +
+
+ ); + } + + const uniformColor = String( + styleObject.borderColor ?? + SUPPORTED_CSS_PROPERTIES.borderColor.defaultValue, + ); + + return ( +
+ + +
+ onChange('borderWidth', v)} + /> + {modeToggle} +
+
+ + + + onChange('borderColor', v)} + /> + +
+ ); +} + +function BorderWidthInput({ + icon, + value, + onChange, + className, +}: { + icon?: React.ReactNode; + value: string | number | undefined; + onChange: (value: number | '') => void; + className?: string; +}) { + const { displayValue, ...handlers } = useNumericInput({ + value, + onCommit: onChange, + allowEmpty: true, + min: 0, + }); + + const { dragProps } = useDragToChange({ + value, + onCommit: onChange, + min: 0, + }); + + return ( + + {icon && ( + + {icon} + + )} + + + px + + + ); +} diff --git a/packages/editor/src/ui/inspector/components/border-radius-picker.tsx b/packages/editor/src/ui/inspector/components/border-radius-picker.tsx new file mode 100644 index 0000000000..06b7c459ff --- /dev/null +++ b/packages/editor/src/ui/inspector/components/border-radius-picker.tsx @@ -0,0 +1,271 @@ +'use client'; + +import * as React from 'react'; +import { + CornerBottomLeftIcon, + CornerBottomRightIcon, + CornerTopLeftIcon, + CornerTopRightIcon, + SquareDashedIcon, + SquareIcon, +} from '../../icons'; +import { useDragToChange } from '../hooks/use-drag-to-change'; +import { useNumericInput } from '../hooks/use-numeric-input'; +import { Label, TextField, ToggleGroup, Tooltip } from '../primitives'; +import { PropRow } from './prop-row'; + +interface BorderRadiusPickerProps { + value: string | number; + onChange: (values: string) => void; + unit?: 'px' | '%'; +} + +export function BorderRadiusPicker({ + value, + onChange, + unit = 'px', +}: BorderRadiusPickerProps) { + const expandedValues = expandShorthand(value); + const allEqual = Object.values(expandedValues).every( + (v) => v === expandedValues.borderTopLeftRadius, + ); + + const [expanded, setExpanded] = React.useState(!allEqual); + + const handleModeChange = (mode: string) => { + if (mode === 'uniform' && expanded) { + const uniform = + expandedValues.borderTopLeftRadius ?? + expandedValues.borderTopRightRadius ?? + expandedValues.borderBottomRightRadius ?? + expandedValues.borderBottomLeftRadius ?? + 0; + onChange(`${uniform}${unit}`); + setExpanded(false); + } else if (mode === 'individual' && !expanded) { + setExpanded(true); + } + }; + + const modeToggle = ( + + + + + + + + Uniform + + + + + + + + Per corner + + + ); + + if (expanded) { + return ( +
+ + + {modeToggle} + + +
+
+ } + value={expandedValues.borderTopLeftRadius} + onChange={(v) => + onChange( + collapseToShorthand({ + ...expandedValues, + borderTopLeftRadius: v, + unit, + }), + ) + } + unit={unit} + /> + } + value={expandedValues.borderTopRightRadius} + onChange={(v) => + onChange( + collapseToShorthand({ + ...expandedValues, + borderTopRightRadius: v, + unit, + }), + ) + } + unit={unit} + /> +
+
+ } + value={expandedValues.borderBottomLeftRadius} + onChange={(v) => + onChange( + collapseToShorthand({ + ...expandedValues, + borderBottomLeftRadius: v, + unit, + }), + ) + } + unit={unit} + /> + } + value={expandedValues.borderBottomRightRadius} + onChange={(v) => + onChange( + collapseToShorthand({ + ...expandedValues, + borderBottomRightRadius: v, + unit, + }), + ) + } + unit={unit} + /> +
+
+
+ ); + } + + return ( + + +
+ onChange(`${v}${unit}`)} + unit={unit} + /> + {modeToggle} +
+
+ ); +} + +function RadiusInput({ + icon, + value, + onChange, + unit, +}: { + icon?: React.ReactNode; + value: number | undefined; + onChange: (value: number) => void; + unit: string; +}) { + const onCommit = (v: number | '') => onChange(v === '' ? 0 : v); + const { displayValue, ...handlers } = useNumericInput({ + value, + onCommit, + allowEmpty: false, + min: 0, + }); + + const { dragProps } = useDragToChange({ + value, + onCommit, + min: 0, + }); + + return ( + + {icon && ( + + {icon} + + )} + + + {unit} + + + ); +} + +function expandShorthand(value: string | number): { + borderTopLeftRadius: number; + borderTopRightRadius: number; + borderBottomRightRadius: number; + borderBottomLeftRadius: number; +} { + if (typeof value === 'number') { + return { + borderTopLeftRadius: value, + borderTopRightRadius: value, + borderBottomRightRadius: value, + borderBottomLeftRadius: value, + }; + } + + const [topLeft, topRight, bottomRight, bottomLeft] = value + .split(' ') + .map((v) => Number.parseInt(v, 10)); + + return { + borderTopLeftRadius: topLeft, + borderTopRightRadius: topRight ?? topLeft, + borderBottomRightRadius: bottomRight ?? topLeft, + borderBottomLeftRadius: bottomLeft ?? topRight ?? topLeft, + }; +} + +function collapseToShorthand(values: { + borderTopLeftRadius: number; + borderTopRightRadius: number; + borderBottomRightRadius: number; + borderBottomLeftRadius: number; + unit: string; +}): string { + const { + borderTopLeftRadius, + borderTopRightRadius, + borderBottomRightRadius, + borderBottomLeftRadius, + unit, + } = values; + + if ( + borderTopLeftRadius === borderTopRightRadius && + borderTopLeftRadius === borderBottomRightRadius && + borderTopLeftRadius === borderBottomLeftRadius + ) { + return `${borderTopLeftRadius}${unit}`; + } + + if ( + borderTopLeftRadius === borderBottomRightRadius && + borderTopRightRadius === borderBottomLeftRadius + ) { + return `${borderTopLeftRadius}${unit} ${borderTopRightRadius}${unit}`; + } + + if (borderTopRightRadius === borderBottomLeftRadius) { + return `${borderTopLeftRadius}${unit} ${borderTopRightRadius}${unit} ${borderBottomRightRadius}${unit}`; + } + + return `${borderTopLeftRadius}${unit} ${borderTopRightRadius}${unit} ${borderBottomRightRadius}${unit} ${borderBottomLeftRadius}${unit}`; +} diff --git a/packages/editor/src/ui/inspector/components/number-input.tsx b/packages/editor/src/ui/inspector/components/number-input.tsx new file mode 100644 index 0000000000..d417209152 --- /dev/null +++ b/packages/editor/src/ui/inspector/components/number-input.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { useDragToChange } from '../hooks/use-drag-to-change'; +import { useNumericInput } from '../hooks/use-numeric-input'; +import { Select, TextField } from '../primitives'; + +interface NumberInputProps { + icon?: React.ReactNode; + value: string | number; + onChange: (value: number | '') => void; + placeholder?: string; + unit?: 'px' | '%'; + min?: number; + unitOptions?: string[]; + onUnitChange?: (unit: string) => void; +} + +export function NumberInput({ + icon, + value, + onChange, + placeholder, + unit, + min, + unitOptions, + onUnitChange, +}: NumberInputProps) { + const { displayValue, ...handlers } = useNumericInput({ + value, + onCommit: onChange, + min, + fallbackValue: placeholder ? Number(placeholder) : undefined, + }); + + const { dragProps } = useDragToChange({ + value, + onCommit: onChange, + min, + }); + + const hasUnitSelect = unitOptions && unitOptions.length > 1 && onUnitChange; + + return ( + + {icon && ( + + {icon} + + )} + + {hasUnitSelect ? ( + onUnitChange(e.target.value)} + > + {unitOptions.map((opt) => ( + + {opt} + + ))} + + ) : ( + unit && ( + + {unit} + + ) + )} + + ); +} diff --git a/packages/editor/src/ui/inspector/components/padding-picker.tsx b/packages/editor/src/ui/inspector/components/padding-picker.tsx new file mode 100644 index 0000000000..f562da7f14 --- /dev/null +++ b/packages/editor/src/ui/inspector/components/padding-picker.tsx @@ -0,0 +1,201 @@ +'use client'; + +import * as React from 'react'; +import { + PanelBottomIcon, + PanelLeftIcon, + PanelRightIcon, + PanelTopIcon, + SquareDashedIcon, + SquareIcon, +} from '../../icons'; +import { useDragToChange } from '../hooks/use-drag-to-change'; +import { useNumericInput } from '../hooks/use-numeric-input'; +import { Label, TextField, ToggleGroup, Tooltip } from '../primitives'; +import { PropRow } from './prop-row'; + +interface PaddingValues { + paddingTop: number; + paddingRight: number; + paddingBottom: number; + paddingLeft: number; +} + +interface PaddingPickerProps { + value: Partial; + onChange: (values: Partial) => void; + unit?: 'px' | '%'; +} + +export function PaddingPicker({ + value, + onChange, + unit = 'px', +}: PaddingPickerProps) { + const allEqual = + value.paddingTop === value.paddingBottom && + value.paddingTop === value.paddingLeft && + value.paddingTop === value.paddingRight; + + const [expanded, setExpanded] = React.useState(!allEqual); + + const handleChange = (key: keyof PaddingValues, newValue: number) => { + onChange({ + paddingTop: value.paddingTop ?? 0, + paddingRight: value.paddingRight ?? 0, + paddingBottom: value.paddingBottom ?? 0, + paddingLeft: value.paddingLeft ?? 0, + [key]: newValue, + }); + }; + + const handleUniformChange = (newValue: number) => { + onChange({ + paddingTop: newValue, + paddingRight: newValue, + paddingBottom: newValue, + paddingLeft: newValue, + }); + }; + + const handleModeChange = (mode: string) => { + if (mode === 'uniform' && expanded) { + const uniform = value.paddingTop ?? 0; + onChange({ + paddingTop: uniform, + paddingRight: uniform, + paddingBottom: uniform, + paddingLeft: uniform, + }); + setExpanded(false); + } else if (mode === 'individual' && !expanded) { + setExpanded(true); + } + }; + + const modeToggle = ( + + + + + + + + Uniform + + + + + + + + Per side + + + ); + + if (expanded) { + return ( +
+ + + {modeToggle} + + +
+
+ } + value={value.paddingTop} + onChange={(v) => handleChange('paddingTop', v)} + unit={unit} + /> + } + value={value.paddingRight} + onChange={(v) => handleChange('paddingRight', v)} + unit={unit} + /> +
+
+ } + value={value.paddingBottom} + onChange={(v) => handleChange('paddingBottom', v)} + unit={unit} + /> + } + value={value.paddingLeft} + onChange={(v) => handleChange('paddingLeft', v)} + unit={unit} + /> +
+
+
+ ); + } + + return ( + + +
+ + {modeToggle} +
+
+ ); +} + +function PaddingInput({ + icon, + value, + onChange, + unit, +}: { + icon?: React.ReactNode; + value: number | undefined; + onChange: (value: number) => void; + unit: string; +}) { + const onCommit = (v: number | '') => onChange(v === '' ? 0 : v); + const { displayValue, ...handlers } = useNumericInput({ + value, + onCommit, + allowEmpty: false, + min: 0, + }); + + const { dragProps } = useDragToChange({ + value, + onCommit, + min: 0, + }); + + return ( + + {icon && ( + + {icon} + + )} + + + {unit} + + + ); +} diff --git a/packages/editor/src/ui/inspector/components/prop-row.tsx b/packages/editor/src/ui/inspector/components/prop-row.tsx new file mode 100644 index 0000000000..cca6e5a7e3 --- /dev/null +++ b/packages/editor/src/ui/inspector/components/prop-row.tsx @@ -0,0 +1,12 @@ +interface PropRowProps { + children: React.ReactNode; + className?: string; +} + +export function PropRow({ children, className }: PropRowProps) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/editor/src/ui/inspector/components/section.tsx b/packages/editor/src/ui/inspector/components/section.tsx new file mode 100644 index 0000000000..98ce1e51a9 --- /dev/null +++ b/packages/editor/src/ui/inspector/components/section.tsx @@ -0,0 +1,58 @@ +'use client'; + +import * as React from 'react'; +import { MinusIcon, PlusIcon } from '../../icons'; +import { IconButton, Text, Tooltip } from '../primitives'; + +interface SectionProps { + title?: string; + children?: React.ReactNode; + onAdd?: () => void; + onRemove?: () => void; +} + +export function Section({ title, children, onAdd, onRemove }: SectionProps) { + const [collapsed, setCollapsed] = React.useState(false); + + return ( +
+ {title && ( +
+ + {collapsed && onAdd && ( + + + + + + + {`Add ${title}`} + + )} + {!collapsed && onRemove && ( + + + + + + + {`Remove ${title}`} + + )} +
+ )} + {!collapsed && children && ( +
{children}
+ )} +
+ ); +} diff --git a/packages/editor/src/ui/inspector/config/attribute-schema.ts b/packages/editor/src/ui/inspector/config/attribute-schema.ts new file mode 100644 index 0000000000..25c8c095af --- /dev/null +++ b/packages/editor/src/ui/inspector/config/attribute-schema.ts @@ -0,0 +1,143 @@ +import { loadPrismTheme } from '../../../utils/prism-utils'; + +interface LocalPropConfig { + label: string; + type: 'text' | 'number' | 'select' | 'textarea'; + defaultValue: string | number; + unit?: 'px' | '%'; + placeholder?: string; + options?: Record; + customUpdate?: (context: { newValue: string }) => void; +} + +export const LOCAL_PROPS_SCHEMA: Record = { + class: { + label: 'Class', + type: 'text', + defaultValue: '', + }, + width: { + label: 'Width', + type: 'number', + unit: 'px', + placeholder: 'auto', + defaultValue: 100, + }, + height: { + label: 'Height', + type: 'number', + unit: 'px', + placeholder: 'auto', + defaultValue: 100, + }, + alt: { + label: 'Alt', + type: 'textarea', + defaultValue: '', + }, + align: { + label: 'Align', + type: 'select', + defaultValue: 'left', + options: { + left: 'Left', + center: 'Center', + right: 'Right', + }, + }, + alignment: { + label: 'Alignment', + type: 'select', + defaultValue: 'left', + options: { + left: 'Left', + center: 'Center', + right: 'Right', + }, + }, + level: { + label: 'Level', + type: 'number', + defaultValue: 1, + }, + src: { + label: 'Source', + type: 'text', + defaultValue: '', + options: { + enableVariables: true, + }, + }, + href: { + label: 'Link', + type: 'text', + defaultValue: '', + }, + title: { + label: 'Title', + type: 'text', + defaultValue: '', + }, + language: { + label: 'Language', + type: 'select', + defaultValue: 'javascript', + options: { + css: 'CSS', + go: 'Go', + html: 'HTML', + javascript: 'JavaScript', + jsx: 'JSX', + json: 'JSON', + markdown: 'Markdown', + php: 'PHP', + plaintext: 'Plain text', + python: 'Python', + ruby: 'Ruby', + shell: 'Shell', + sql: 'SQL', + svg: 'svg', + typescript: 'TypeScript', + }, + }, + theme: { + label: 'Theme', + type: 'select', + defaultValue: 'default', + options: { + default: 'Default', + atomDark: 'Atom Dark', + oneLight: 'One Light', + dracula: 'Dracula', + nord: 'Nord', + duotoneDark: 'Duotone Dark', + duotoneForest: 'Duotone Forest', + duotoneLight: 'Duotone Light', + duotoneSea: 'Duotone Sea', + duotoneSpace: 'Duotone Space', + vesper: 'Vesper', + vs: 'VSCode Light', + vscDarkPlus: 'VSCode Dark', + }, + customUpdate: ({ newValue }: { newValue: string }) => { + loadPrismTheme(newValue); + }, + }, +}; + +export const EXCLUDED_ATTRIBUTES = [ + 'style', + 'class', + 'width', + 'height', + 'align', + 'alignment', + 'href', + 'id', + 'title', + 'lang', + 'dir', + 'data-id', +]; + +export const LIVEBLOCKS_INTERNAL_PROPS = ['ychange']; diff --git a/packages/editor/src/ui/inspector/config/node-meta.ts b/packages/editor/src/ui/inspector/config/node-meta.ts new file mode 100644 index 0000000000..3c61cd145d --- /dev/null +++ b/packages/editor/src/ui/inspector/config/node-meta.ts @@ -0,0 +1,50 @@ +import type { ElementType } from 'react'; +import { + BoxIcon, + CodeIcon, + Heading1Icon, + ImageIcon, + LayoutIcon, + LinkIcon, + ListIcon, + MinusIcon, + MousePointerClickIcon, + SquareRoundCornerIcon, + TableIcon, + TextQuoteIcon, + TypeIcon, +} from '../../icons'; + +export interface NodeMeta { + icon: ElementType; + label: string; +} + +const NODE_META: Record = { + body: { icon: LayoutIcon, label: 'Body' }, + paragraph: { icon: TypeIcon, label: 'Text' }, + heading: { icon: Heading1Icon, label: 'Heading' }, + image: { icon: ImageIcon, label: 'Image' }, + button: { icon: MousePointerClickIcon, label: 'Button' }, + link: { icon: LinkIcon, label: 'Link' }, + codeBlock: { icon: CodeIcon, label: 'Code Block' }, + section: { icon: LayoutIcon, label: 'Section' }, + div: { icon: BoxIcon, label: 'Container' }, + footer: { icon: BoxIcon, label: 'Footer' }, + blockquote: { icon: TextQuoteIcon, label: 'Blockquote' }, + bulletList: { icon: ListIcon, label: 'Bullet List' }, + orderedList: { icon: ListIcon, label: 'Ordered List' }, + listItem: { icon: MinusIcon, label: 'List Item' }, + table: { icon: TableIcon, label: 'Table' }, + tableRow: { icon: MinusIcon, label: 'Table Row' }, + tableCell: { icon: BoxIcon, label: 'Table Cell' }, + tableHeader: { icon: BoxIcon, label: 'Table Header' }, + horizontalRule: { icon: MinusIcon, label: 'Divider' }, + global: { icon: SquareRoundCornerIcon, label: 'Layout' }, +}; + +const DEFAULT_NODE_META: NodeMeta = { icon: BoxIcon, label: 'Element' }; + +export function getNodeMeta(nodeType: string): NodeMeta { + return NODE_META[nodeType] ?? DEFAULT_NODE_META; +} diff --git a/packages/editor/src/ui/inspector/config/text-config.tsx b/packages/editor/src/ui/inspector/config/text-config.tsx new file mode 100644 index 0000000000..fe5da92794 --- /dev/null +++ b/packages/editor/src/ui/inspector/config/text-config.tsx @@ -0,0 +1,162 @@ +import type { ElementType } from 'react'; +import { + AlignCenterIcon, + AlignCenterVerticalIcon, + AlignEndVerticalIcon, + AlignLeftIcon, + AlignRightIcon, + AlignStartVerticalIcon, + BoldIcon, + CaseUpperIcon, + CodeIcon, + ItalicIcon, + ListIcon, + ListOrderedIcon, + StrikethroughIcon, + UnderlineIcon, +} from '../../icons'; + +export interface ParentBlockInfo { + alignment: string; + pos: number; + nodeType: string; + attrs: Record; +} + +export interface EditorSnapshot { + isBoldActive: boolean; + isItalicActive: boolean; + isUnderlineActive: boolean; + isStrikeActive: boolean; + isCodeActive: boolean; + isUppercaseActive: boolean; + isBulletListActive: boolean; + isOrderedListActive: boolean; + isBlockquoteActive: boolean; + currentColor: string | undefined; + parentBlock: ParentBlockInfo; + blockStyle: Record; +} + +export const PADDING_KEYS = [ + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', +] as const; + +export const CSS_UNIT_MAP: Record = { + fontSize: 'px', + borderWidth: 'px', + borderRadius: 'px', + paddingTop: 'px', + paddingRight: 'px', + paddingBottom: 'px', + paddingLeft: 'px', + lineHeight: '%', +}; + +export const TEXT_TYPE_OPTIONS = [ + { value: 'title', label: 'Title', nodeType: 'heading', level: 1 }, + { value: 'subtitle', label: 'Subtitle', nodeType: 'heading', level: 2 }, + { value: 'heading', label: 'Heading', nodeType: 'heading', level: 3 }, + { value: 'body', label: 'Body', nodeType: 'paragraph' }, +] as const; + +export interface FormatItem { + value: string; + icon: ElementType; + iconClassName?: string; + label: string; +} + +export interface AlignmentItem { + value: string; + icon: ElementType; + alternativeIcon?: ElementType; + iconClassName?: string; + alternativeIconClassName?: string; +} + +export interface ListItem { + value: string; + icon: ElementType; + iconClassName?: string; + label: string; +} + +export const MARK_TOGGLES = [ + { + value: 'bold', + active: (s: EditorSnapshot) => s.isBoldActive, + toggle: 'toggleBold', + }, + { + value: 'italic', + active: (s: EditorSnapshot) => s.isItalicActive, + toggle: 'toggleItalic', + }, + { + value: 'underline', + active: (s: EditorSnapshot) => s.isUnderlineActive, + toggle: 'toggleUnderline', + }, + { + value: 'line-through', + active: (s: EditorSnapshot) => s.isStrikeActive, + toggle: 'toggleStrike', + }, + { + value: 'code', + active: (s: EditorSnapshot) => s.isCodeActive, + toggle: 'toggleCode', + }, + { + value: 'uppercase', + active: (s: EditorSnapshot) => s.isUppercaseActive, + toggle: 'toggleUppercase', + }, + { + value: 'blockquote', + active: (s: EditorSnapshot) => s.isBlockquoteActive, + toggle: 'toggleBlockquote', + }, +] as const; + +export const FORMAT_ITEMS: FormatItem[] = [ + { value: 'bold', icon: BoldIcon, label: 'Bold' }, + { value: 'italic', icon: ItalicIcon, label: 'Italic' }, + { value: 'underline', icon: UnderlineIcon, label: 'Underline' }, + { value: 'line-through', icon: StrikethroughIcon, label: 'Strikethrough' }, + { value: 'code', icon: CodeIcon, label: 'Code' }, + { value: 'uppercase', icon: CaseUpperIcon, label: 'Uppercase' }, +]; + +export const ALIGNMENT_ITEMS: AlignmentItem[] = [ + { + value: 'left', + alternativeIcon: AlignLeftIcon, + icon: AlignStartVerticalIcon, + }, + { + value: 'center', + alternativeIcon: AlignCenterIcon, + icon: AlignCenterVerticalIcon, + }, + { + value: 'right', + alternativeIcon: AlignRightIcon, + icon: AlignEndVerticalIcon, + }, +]; + +export const JUSTIFY_AND_LIST_ITEMS: ListItem[] = [ + { value: 'bulletList', icon: ListIcon, label: 'Bullet list' }, + { value: 'orderedList', icon: ListOrderedIcon, label: 'Ordered list' }, +]; + +export const TEXT_DECORATION_ITEMS: FormatItem[] = [ + { value: 'none', icon: () => null, label: 'None' }, + { value: 'underline', icon: UnderlineIcon, label: 'Underline' }, + { value: 'line-through', icon: StrikethroughIcon, label: 'Strikethrough' }, +]; diff --git a/packages/editor/src/ui/inspector/document.tsx b/packages/editor/src/ui/inspector/document.tsx new file mode 100644 index 0000000000..93cbef412a --- /dev/null +++ b/packages/editor/src/ui/inspector/document.tsx @@ -0,0 +1,330 @@ +import { useCurrentEditor } from '@tiptap/react'; +import { + setGlobalStyles, + useEmailTheming, +} from '../../plugins/email-theming/extension'; +import { + EDITOR_THEMES, + SUPPORTED_CSS_PROPERTIES, +} from '../../plugins/email-theming/themes'; +import type { + KnownCssProperties, + KnownThemeComponents, + PanelGroup, +} from '../../plugins/email-theming/types'; +import { NumberInput } from './components/number-input'; +import { PropRow } from './components/prop-row'; +import { Section } from './components/section'; +import { ColorInput, Label } from './primitives'; +import { useInspector } from './root'; + +function ensureAllProperties( + currentStyles: PanelGroup[], + themeDefaults: PanelGroup[], +): PanelGroup[] { + return currentStyles.map((group) => { + const defaultGroup = themeDefaults.find((g) => + group.id ? g.id === group.id : g.title === group.title, + ); + + if (!defaultGroup || defaultGroup.inputs.length === 0) { + return group; + } + + const existingProps = new Set( + group.inputs.map((i) => `${i.classReference}:${i.prop}`), + ); + + const missingInputs = defaultGroup.inputs + .filter( + (defaultInput) => + !existingProps.has( + `${defaultInput.classReference}:${defaultInput.prop}`, + ), + ) + .map((defaultInput) => { + const propDef = SUPPORTED_CSS_PROPERTIES[defaultInput.prop]; + + if (propDef && propDef.type === 'number') { + return { + ...defaultInput, + value: '' as string | number, + placeholder: String(propDef.defaultValue), + }; + } + + return { ...defaultInput }; + }); + + if (missingInputs.length === 0) { + return group; + } + + return { + ...group, + inputs: [...group.inputs, ...missingInputs], + }; + }); +} + +function applyStyleChange( + styles: PanelGroup[], + themeName: 'basic' | 'minimal', + { + classReference, + prop, + newValue, + }: { + classReference?: string; + prop: string; + newValue: string | number; + }, +): PanelGroup[] { + let found = false; + + const updatedStyles = styles.map((styleGroup) => { + const matchingInput = styleGroup.inputs.find( + (input) => input.classReference === classReference && input.prop === prop, + ); + + if (matchingInput) { + found = true; + return { + ...styleGroup, + inputs: styleGroup.inputs.map((input) => { + if (input.classReference === classReference && input.prop === prop) { + return { ...input, value: newValue }; + } + return input; + }), + }; + } + + return styleGroup; + }); + + if (found) { + return updatedStyles; + } + + const propDef = SUPPORTED_CSS_PROPERTIES[prop as KnownCssProperties] ?? null; + + return updatedStyles.map((styleGroup) => { + if (styleGroup.classReference !== classReference) { + return styleGroup; + } + + const themeDefaults = EDITOR_THEMES[themeName]; + const defaultGroup = themeDefaults.find((g) => + styleGroup.id ? g.id === styleGroup.id : g.title === styleGroup.title, + ); + const defaultInput = defaultGroup?.inputs.find( + (i) => i.prop === prop && i.classReference === classReference, + ); + + if (defaultInput) { + return { + ...styleGroup, + inputs: [...styleGroup.inputs, { ...defaultInput, value: newValue }], + }; + } + + if (propDef) { + return { + ...styleGroup, + inputs: [ + ...styleGroup.inputs, + { + label: propDef.label, + type: propDef.type, + value: newValue, + prop: prop as KnownCssProperties, + classReference: classReference as KnownThemeComponents | undefined, + unit: propDef.unit, + options: propDef.options, + }, + ], + }; + } + + return styleGroup; + }); +} + +export type SetGlobalStyle = ( + classReference: KnownThemeComponents, + property: KnownCssProperties, + value: unknown, +) => void; + +export type BatchSetGlobalStyle = ( + changes: Array<{ + classReference: KnownThemeComponents; + property: KnownCssProperties; + value: unknown; + }>, +) => void; + +export type FindStyleValue = ( + classReference: KnownThemeComponents, + prop: KnownCssProperties, +) => string | number; + +export interface InspectorDocumentContext { + styles: PanelGroup[]; + setGlobalStyle: SetGlobalStyle; + batchSetGlobalStyle: BatchSetGlobalStyle; + findStyleValue: FindStyleValue; +} + +export interface InspectorDocumentProps { + children?: (context: InspectorDocumentContext) => React.ReactNode; +} + +export function InspectorDocument({ children }: InspectorDocumentProps) { + const { editor } = useCurrentEditor(); + const theming = useEmailTheming(editor); + const { target } = useInspector(); + + if (!editor || !theming) { + return null; + } + + const themeDefaults = EDITOR_THEMES[theming.theme]; + + const groups = ensureAllProperties(theming.styles, themeDefaults); + + function setGlobalStyle( + classReference: KnownThemeComponents, + property: KnownCssProperties, + value: unknown, + ) { + const newStyles = applyStyleChange(theming!.styles, theming!.theme, { + classReference, + prop: property, + newValue: value as string | number, + }); + setGlobalStyles(editor!, newStyles); + } + + function batchSetGlobalStyle( + changes: Array<{ + classReference: KnownThemeComponents; + property: KnownCssProperties; + value: unknown; + }>, + ) { + let styles = theming!.styles; + for (const change of changes) { + styles = applyStyleChange(styles, theming!.theme, { + classReference: change.classReference, + prop: change.property, + newValue: change.value as string | number, + }); + } + setGlobalStyles(editor!, styles); + } + + function findStyleValue( + classReference: KnownThemeComponents, + prop: KnownCssProperties, + ): string | number { + for (const group of groups) { + const input = group.inputs.find( + (i) => i.classReference === classReference && i.prop === prop, + ); + if (input && input.value !== undefined) return input.value; + } + + for (const group of themeDefaults) { + const input = group.inputs.find( + (i) => i.classReference === classReference && i.prop === prop, + ); + if (input && input.value !== undefined) return input.value; + } + + const propDef = SUPPORTED_CSS_PROPERTIES[prop]; + return propDef?.defaultValue ?? ''; + } + + if (typeof target !== 'object' || target.nodeType !== 'body') { + return null; + } + + const context: InspectorDocumentContext = { + styles: groups, + setGlobalStyle, + batchSetGlobalStyle, + findStyleValue, + }; + + if (children) { + return <>{children(context)}; + } + + return ; +} + +function InspectorDocumentDefaults({ + context, +}: { + context: InspectorDocumentContext; +}) { + const { findStyleValue, setGlobalStyle } = context; + + return ( + <> +
+ + + setGlobalStyle('body', 'backgroundColor', v)} + /> + + + + setGlobalStyle('body', 'padding', v)} + unit="px" + /> + +
+ +
+ + + setGlobalStyle('container', 'backgroundColor', v)} + /> + + + + setGlobalStyle('container', 'width', v)} + unit="px" + /> + + + + setGlobalStyle('container', 'padding', v)} + unit="px" + /> + + + + setGlobalStyle('container', 'borderRadius', v)} + unit="px" + /> + +
+ + ); +} diff --git a/packages/editor/src/ui/inspector/hooks/use-document-colors.ts b/packages/editor/src/ui/inspector/hooks/use-document-colors.ts new file mode 100644 index 0000000000..b6b6daef5a --- /dev/null +++ b/packages/editor/src/ui/inspector/hooks/use-document-colors.ts @@ -0,0 +1,150 @@ +import type { useCurrentEditor } from '@tiptap/react'; +import { useEditorState } from '@tiptap/react'; +import * as React from 'react'; +import { + stylesToCss, + useEmailTheming, +} from '../../../plugins/email-theming/extension'; +import { isValidHexColor } from '../utils/is-valid-hex-color'; + +type Editor = ReturnType['editor']; + +const COLOR_CSS_PROPERTIES = [ + 'color', + 'backgroundColor', + 'borderColor', + 'borderTopColor', + 'borderRightColor', + 'borderBottomColor', + 'borderLeftColor', +] as const; + +export function useDocumentColors(editor: Editor): string[] { + const theming = useEmailTheming(editor)!; + + const globalColors = React.useMemo(() => { + const colors = new Set(); + const cssJs = stylesToCss(theming.styles, theming.theme); + + for (const componentStyles of Object.values(cssJs)) { + if (!componentStyles || typeof componentStyles !== 'object') { + continue; + } + for (const prop of COLOR_CSS_PROPERTIES) { + const value = (componentStyles as Record)[prop]; + if (typeof value === 'string') { + addColor(colors, value); + } + } + } + + return Array.from(colors); + }, [theming]); + + const inlineColors = + useEditorState({ + editor, + selector: ({ editor: ed }: { editor: Editor }): string[] => { + if (!ed) { + return []; + } + + const colors = new Set(); + + ed.state.doc.descendants((node) => { + for (const mark of node.marks) { + if (mark.type.name === 'textStyle' && mark.attrs.color) { + addColor(colors, mark.attrs.color); + } + } + + const style = node.attrs.style as string | undefined; + if (style) { + extractColorsFromInlineStyle(colors, style); + } + }); + + return Array.from(colors); + }, + equalityFn: (a: string[] | null, b: string[] | null) => { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + return arrayShallowEqual(a, b); + }, + }) ?? []; + + return React.useMemo(() => { + if (globalColors.length === 0) { + return inlineColors; + } + if (inlineColors.length === 0) { + return globalColors; + } + const merged = new Set([...globalColors, ...inlineColors]); + return Array.from(merged); + }, [globalColors, inlineColors]); +} + +const COLOR_STYLE_PROPS = [ + 'color', + 'background-color', + 'border-color', + 'border-top-color', + 'border-right-color', + 'border-bottom-color', + 'border-left-color', +]; + +function extractColorsFromInlineStyle( + colors: Set, + style: string, +): void { + for (const prop of COLOR_STYLE_PROPS) { + const regex = new RegExp(`${prop}\\s*:\\s*([^;]+)`, 'i'); + const match = style.match(regex); + if (match) { + addColor(colors, match[1].trim()); + } + } +} + +function addColor(colors: Set, raw: string): void { + const value = raw.trim().toLowerCase(); + if (!value || value === '#000000' || value === '#ffffff') { + return; + } + if (isValidHexColor(value)) { + const normalized = expandToSix(value); + colors.add(normalized); + } +} + +function expandToSix(hex: string): string { + const h = hex.slice(1); + if (h.length === 3) { + return `#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}`; + } + if (h.length === 4) { + return `#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}`; + } + if (h.length === 8) { + return `#${h.slice(0, 6)}`; + } + return hex; +} + +function arrayShallowEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +} diff --git a/packages/editor/src/ui/inspector/hooks/use-drag-to-change.spec.ts b/packages/editor/src/ui/inspector/hooks/use-drag-to-change.spec.ts new file mode 100644 index 0000000000..e461e8f1cf --- /dev/null +++ b/packages/editor/src/ui/inspector/hooks/use-drag-to-change.spec.ts @@ -0,0 +1,151 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { useDragToChange } from './use-drag-to-change'; + +function createPointerEvent(overrides: Partial = {}) { + return { + preventDefault: vi.fn(), + clientX: 0, + pointerId: 1, + shiftKey: false, + currentTarget: { + setPointerCapture: vi.fn(), + } as unknown as HTMLElement, + ...overrides, + } as unknown as React.PointerEvent; +} + +describe('useDragToChange', () => { + it('calls onCommit with delta value on pointer move after pointer down', () => { + const onCommit = vi.fn(); + const { result } = renderHook(() => + useDragToChange({ value: 10, onCommit }), + ); + + act(() => { + result.current.dragProps.onPointerDown( + createPointerEvent({ clientX: 100 }), + ); + }); + + act(() => { + result.current.dragProps.onPointerMove( + createPointerEvent({ clientX: 110 }), + ); + }); + + expect(onCommit).toHaveBeenCalledWith(15); + }); + + it('does not call onCommit on pointer move without pointer down', () => { + const onCommit = vi.fn(); + const { result } = renderHook(() => + useDragToChange({ value: 10, onCommit }), + ); + + act(() => { + result.current.dragProps.onPointerMove( + createPointerEvent({ clientX: 110 }), + ); + }); + + expect(onCommit).not.toHaveBeenCalled(); + }); + + it('stops tracking after pointer up', () => { + const onCommit = vi.fn(); + const { result } = renderHook(() => + useDragToChange({ value: 10, onCommit }), + ); + + act(() => { + result.current.dragProps.onPointerDown( + createPointerEvent({ clientX: 100 }), + ); + }); + + act(() => { + result.current.dragProps.onPointerUp(); + }); + + act(() => { + result.current.dragProps.onPointerMove( + createPointerEvent({ clientX: 120 }), + ); + }); + + expect(onCommit).not.toHaveBeenCalled(); + }); + + it('respects step parameter', () => { + const onCommit = vi.fn(); + const { result } = renderHook(() => + useDragToChange({ value: 0, onCommit, step: 5 }), + ); + + act(() => { + result.current.dragProps.onPointerDown( + createPointerEvent({ clientX: 0 }), + ); + }); + + act(() => { + result.current.dragProps.onPointerMove( + createPointerEvent({ clientX: 4 }), + ); + }); + + expect(onCommit).toHaveBeenCalledWith(10); + }); + + it('sets cursor to ew-resize during drag', () => { + const onCommit = vi.fn(); + const { result } = renderHook(() => + useDragToChange({ value: 0, onCommit }), + ); + + act(() => { + result.current.dragProps.onPointerDown( + createPointerEvent({ clientX: 0 }), + ); + }); + + expect(document.body.style.cursor).toBe('ew-resize'); + + act(() => { + result.current.dragProps.onPointerUp(); + }); + + expect(document.body.style.cursor).toBe(''); + }); + + it('clamps to min value', () => { + const onCommit = vi.fn(); + const { result } = renderHook(() => + useDragToChange({ value: 5, onCommit, min: 0 }), + ); + + act(() => { + result.current.dragProps.onPointerDown( + createPointerEvent({ clientX: 100 }), + ); + }); + + act(() => { + result.current.dragProps.onPointerMove( + createPointerEvent({ clientX: 80 }), + ); + }); + + expect(onCommit).toHaveBeenCalledWith(0); + }); + + it('returns ew-resize cursor style in dragProps', () => { + const onCommit = vi.fn(); + const { result } = renderHook(() => + useDragToChange({ value: 0, onCommit }), + ); + + expect(result.current.dragProps.style.cursor).toBe('ew-resize'); + }); +}); diff --git a/packages/editor/src/ui/inspector/hooks/use-drag-to-change.ts b/packages/editor/src/ui/inspector/hooks/use-drag-to-change.ts new file mode 100644 index 0000000000..e7c15f58f9 --- /dev/null +++ b/packages/editor/src/ui/inspector/hooks/use-drag-to-change.ts @@ -0,0 +1,76 @@ +import * as React from 'react'; + +interface UseDragToChangeOptions { + value: string | number | undefined | null; + onCommit: (value: number | '') => void; + min?: number; + step?: number; +} + +export function useDragToChange({ + value, + onCommit, + min, + step = 1, +}: UseDragToChangeOptions) { + const startXRef = React.useRef(0); + const startValueRef = React.useRef(0); + const isDraggingRef = React.useRef(false); + + React.useEffect(() => { + return () => { + document.body.style.removeProperty('cursor'); + document.body.style.removeProperty('user-select'); + }; + }, []); + + const onPointerDown = React.useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + isDraggingRef.current = true; + startXRef.current = e.clientX; + startValueRef.current = Number(value) || 0; + + document.body.style.cursor = 'ew-resize'; + document.body.style.userSelect = 'none'; + + const target = e.currentTarget as HTMLElement; + target.setPointerCapture(e.pointerId); + }, + [value], + ); + + const onPointerMove = React.useCallback( + (e: React.PointerEvent) => { + if (!isDraggingRef.current) { + return; + } + + const dx = e.clientX - startXRef.current; + const effectiveStep = e.shiftKey ? step * 10 : step; + const delta = Math.round(dx / 2) * effectiveStep; + const next = Math.max( + min ?? Number.NEGATIVE_INFINITY, + startValueRef.current + delta, + ); + onCommit(next); + }, + [onCommit, min, step], + ); + + const onPointerUp = React.useCallback(() => { + isDraggingRef.current = false; + document.body.style.removeProperty('cursor'); + document.body.style.removeProperty('user-select'); + }, []); + + return { + dragProps: { + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel: onPointerUp, + style: { cursor: 'ew-resize' } as React.CSSProperties, + }, + }; +} diff --git a/packages/editor/src/ui/inspector/hooks/use-link-mark.ts b/packages/editor/src/ui/inspector/hooks/use-link-mark.ts new file mode 100644 index 0000000000..d183186d80 --- /dev/null +++ b/packages/editor/src/ui/inspector/hooks/use-link-mark.ts @@ -0,0 +1,58 @@ +import type { Editor } from '@tiptap/core'; +import { useEditorState } from '@tiptap/react'; +import { inlineCssToJs, jsToInlineCss } from '../../../utils/styles'; + +interface LinkMarkState { + href: string; + style: string; + isActive: boolean; +} + +export function useLinkMark(editor: Editor | null): LinkMarkState { + return ( + useEditorState({ + editor, + selector: ({ editor }) => { + if (!editor) { + return { href: '', style: '', isActive: false }; + } + const { from } = editor.state.selection; + const mark = + editor.state.doc + .resolve(from) + .marks() + .find((m) => m.type.name === 'link') ?? null; + return { + href: (mark?.attrs?.href as string) ?? '', + style: (mark?.attrs?.style as string) ?? '', + isActive: !!mark, + }; + }, + }) ?? { href: '', style: '', isActive: false } + ); +} + +export function getLinkColor( + linkStyle: string, + themeLinkColor: string | undefined, + fallback = '#000000', +): string { + return inlineCssToJs(linkStyle).color || themeLinkColor || fallback; +} + +export function updateLinkColor( + editor: Editor, + linkStyle: string, + color: string, +): void { + const styleObj = inlineCssToJs(linkStyle); + styleObj.color = color; + const newStyle = jsToInlineCss(styleObj); + const { from, to } = editor.state.selection; + editor + .chain() + .extendMarkRange('link') + .updateAttributes('link', { style: newStyle }) + .setTextSelection({ from, to }) + .run(); +} diff --git a/packages/editor/src/ui/inspector/hooks/use-numeric-input.spec.ts b/packages/editor/src/ui/inspector/hooks/use-numeric-input.spec.ts new file mode 100644 index 0000000000..115e03bba4 --- /dev/null +++ b/packages/editor/src/ui/inspector/hooks/use-numeric-input.spec.ts @@ -0,0 +1,220 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { useNumericInput } from './use-numeric-input'; + +function createKeyboardEvent( + key: string, + overrides: Partial> = {}, +) { + return { + key, + preventDefault: vi.fn(), + shiftKey: false, + target: { + blur: vi.fn(), + select: vi.fn(), + } as unknown as HTMLInputElement, + ...overrides, + } as unknown as React.KeyboardEvent; +} + +describe('useNumericInput', () => { + it('initializes displayValue from value prop', () => { + const { result } = renderHook(() => + useNumericInput({ value: 42, onCommit: vi.fn() }), + ); + + expect(result.current.displayValue).toBe('42'); + }); + + it('arrow up increments value by 1', () => { + const onCommit = vi.fn(); + const { result } = renderHook(() => + useNumericInput({ value: 10, onCommit }), + ); + + act(() => { + result.current.onKeyDown(createKeyboardEvent('ArrowUp')); + }); + + expect(onCommit).toHaveBeenCalledWith(11); + expect(result.current.displayValue).toBe('11'); + }); + + it('arrow down decrements value by 1', () => { + const onCommit = vi.fn(); + const { result } = renderHook(() => + useNumericInput({ value: 10, onCommit }), + ); + + act(() => { + result.current.onKeyDown(createKeyboardEvent('ArrowDown')); + }); + + expect(onCommit).toHaveBeenCalledWith(9); + expect(result.current.displayValue).toBe('9'); + }); + + it('shift+arrow up increments by 10', () => { + const onCommit = vi.fn(); + const { result } = renderHook(() => + useNumericInput({ value: 10, onCommit }), + ); + + act(() => { + result.current.onKeyDown( + createKeyboardEvent('ArrowUp', { shiftKey: true }), + ); + }); + + expect(onCommit).toHaveBeenCalledWith(20); + expect(result.current.displayValue).toBe('20'); + }); + + it('shift+arrow down decrements by 10', () => { + const onCommit = vi.fn(); + const { result } = renderHook(() => + useNumericInput({ value: 30, onCommit }), + ); + + act(() => { + result.current.onKeyDown( + createKeyboardEvent('ArrowDown', { shiftKey: true }), + ); + }); + + expect(onCommit).toHaveBeenCalledWith(20); + expect(result.current.displayValue).toBe('20'); + }); + + it('clamps to min on arrow down', () => { + const onCommit = vi.fn(); + const { result } = renderHook(() => + useNumericInput({ value: 2, onCommit, min: 0 }), + ); + + act(() => { + result.current.onKeyDown( + createKeyboardEvent('ArrowDown', { shiftKey: true }), + ); + }); + + expect(onCommit).toHaveBeenCalledWith(0); + expect(result.current.displayValue).toBe('0'); + }); + + it('clamps to min on arrow up does not go below min', () => { + const onCommit = vi.fn(); + const { result } = renderHook(() => + useNumericInput({ value: -5, onCommit, min: 0 }), + ); + + act(() => { + result.current.onKeyDown(createKeyboardEvent('ArrowUp')); + }); + + expect(onCommit).toHaveBeenCalledWith(0); + }); + + it('commits on blur', () => { + const onCommit = vi.fn(); + const { result } = renderHook(() => + useNumericInput({ value: 10, onCommit }), + ); + + act(() => { + result.current.onFocus({ + target: { select: vi.fn() }, + } as unknown as React.FocusEvent); + }); + + act(() => { + result.current.onChange({ + target: { value: '25' }, + } as unknown as React.ChangeEvent); + }); + + act(() => { + result.current.onBlur(); + }); + + expect(onCommit).toHaveBeenCalledWith(25); + }); + + it('commits empty string when allowEmpty is true and input is cleared', () => { + const onCommit = vi.fn(); + const { result } = renderHook(() => + useNumericInput({ value: 10, onCommit, allowEmpty: true }), + ); + + act(() => { + result.current.onFocus({ + target: { select: vi.fn() }, + } as unknown as React.FocusEvent); + }); + + act(() => { + result.current.onChange({ + target: { value: '' }, + } as unknown as React.ChangeEvent); + }); + + act(() => { + result.current.onBlur(); + }); + + expect(onCommit).toHaveBeenCalledWith(''); + }); + + it('commits 0 when allowEmpty is false and input is cleared', () => { + const onCommit = vi.fn(); + const { result } = renderHook(() => + useNumericInput({ value: 10, onCommit, allowEmpty: false }), + ); + + act(() => { + result.current.onFocus({ + target: { select: vi.fn() }, + } as unknown as React.FocusEvent); + }); + + act(() => { + result.current.onChange({ + target: { value: '' }, + } as unknown as React.ChangeEvent); + }); + + act(() => { + result.current.onBlur(); + }); + + expect(onCommit).toHaveBeenCalledWith(0); + }); + + it('uses fallbackValue when incrementing from empty', () => { + const onCommit = vi.fn(); + const { result } = renderHook(() => + useNumericInput({ value: '', onCommit, fallbackValue: 16 }), + ); + + act(() => { + result.current.onKeyDown(createKeyboardEvent('ArrowUp')); + }); + + expect(onCommit).toHaveBeenCalledWith(17); + }); + + it('syncs displayValue from external value changes when not focused', () => { + const onCommit = vi.fn(); + const { result, rerender } = renderHook( + ({ value }) => useNumericInput({ value, onCommit }), + { initialProps: { value: 10 as string | number } }, + ); + + expect(result.current.displayValue).toBe('10'); + + rerender({ value: 20 }); + + expect(result.current.displayValue).toBe('20'); + }); +}); diff --git a/packages/editor/src/ui/inspector/hooks/use-numeric-input.ts b/packages/editor/src/ui/inspector/hooks/use-numeric-input.ts new file mode 100644 index 0000000000..4f418edb29 --- /dev/null +++ b/packages/editor/src/ui/inspector/hooks/use-numeric-input.ts @@ -0,0 +1,128 @@ +import * as React from 'react'; + +interface UseNumericInputOptions { + value: string | number | undefined | null; + onCommit: (value: number | '') => void; + allowEmpty?: boolean; + min?: number; + fallbackValue?: number; +} + +interface UseNumericInputReturn { + displayValue: string; + onChange: (e: React.ChangeEvent) => void; + onBlur: () => void; + onFocus: (e: React.FocusEvent) => void; + onKeyDown: (e: React.KeyboardEvent) => void; +} + +function toDisplayString(v: string | number | undefined | null): string { + if (v === '' || v === undefined || v === null || Number.isNaN(v)) { + return ''; + } + return String(v); +} + +export function useNumericInput({ + value, + onCommit, + allowEmpty = true, + min, + fallbackValue, +}: UseNumericInputOptions): UseNumericInputReturn { + const [displayValue, setDisplayValue] = React.useState(() => + toDisplayString(value), + ); + const isFocusedRef = React.useRef(false); + const cancelledRef = React.useRef(false); + + React.useEffect(() => { + if (!isFocusedRef.current) { + setDisplayValue(toDisplayString(value)); + } + }, [value]); + + const commit = React.useCallback( + (raw: string) => { + const trimmed = raw.trim(); + + if (trimmed === '') { + if (allowEmpty) { + onCommit(''); + } else { + setDisplayValue('0'); + onCommit(0); + } + return; + } + + const num = Number(trimmed); + + if (Number.isNaN(num)) { + setDisplayValue(toDisplayString(value)); + return; + } + + const clamped = Math.max(num, min ?? Number.NEGATIVE_INFINITY); + setDisplayValue(String(clamped)); + onCommit(clamped); + }, + [value, onCommit, allowEmpty, min], + ); + + const onChange = React.useCallback( + (e: React.ChangeEvent) => { + setDisplayValue(e.target.value); + }, + [], + ); + + const onBlur = React.useCallback(() => { + isFocusedRef.current = false; + if (cancelledRef.current) { + cancelledRef.current = false; + return; + } + commit(displayValue); + }, [commit, displayValue]); + + const onFocus = React.useCallback((e: React.FocusEvent) => { + isFocusedRef.current = true; + e.target.select(); + }, []); + + const onKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + commit(displayValue); + (e.target as HTMLInputElement).blur(); + } + + if (e.key === 'Escape') { + cancelledRef.current = true; + setDisplayValue(toDisplayString(value)); + (e.target as HTMLInputElement).blur(); + } + + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + const step = e.shiftKey ? 10 : 1; + const trimmed = displayValue.trim(); + const parsed = Number(trimmed); + const current = + trimmed === '' || Number.isNaN(parsed) + ? (fallbackValue ?? 0) + : parsed; + const next = Math.max( + min ?? Number.NEGATIVE_INFINITY, + e.key === 'ArrowUp' ? current + step : current - step, + ); + setDisplayValue(String(next)); + onCommit(next); + } + }, + [commit, displayValue, value, onCommit, min, fallbackValue], + ); + + return { displayValue, onChange, onBlur, onFocus, onKeyDown }; +} diff --git a/packages/editor/src/ui/inspector/index.tsx b/packages/editor/src/ui/inspector/index.tsx new file mode 100644 index 0000000000..4e6e37abd0 --- /dev/null +++ b/packages/editor/src/ui/inspector/index.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { InspectorBreadcrumb } from './breadcrumb'; +import { InspectorDocument } from './document'; +import { InspectorNode } from './node'; +import { InspectorRoot } from './root'; +import { AttributesSection } from './sections/attributes'; +import { BackgroundSection } from './sections/background'; +import { BorderSection } from './sections/border'; +import { ColumnSpacingSection } from './sections/column-spacing'; +import { LinkSection } from './sections/link'; +import { PaddingSection } from './sections/padding'; +import { SizeSection } from './sections/size'; +import { TypographySection } from './sections/typography'; +import { InspectorText } from './text'; + +export const Inspector = { + Root: InspectorRoot, + Breadcrumb: InspectorBreadcrumb, + Document: InspectorDocument, + Node: InspectorNode, + Text: InspectorText, + Attributes: AttributesSection, + Background: BackgroundSection, + Border: BorderSection, + ColumnSpacing: ColumnSpacingSection, + Link: LinkSection, + Padding: PaddingSection, + Size: SizeSection, + Typography: TypographySection, +}; + +export type { NodeMeta } from './config/node-meta'; +export { getNodeMeta } from './config/node-meta'; +export type { InspectorDocumentProps } from './document'; +export type { InspectorNodeContext, InspectorNodeProps } from './node'; +export type { InspectorTextContext, InspectorTextProps } from './text'; diff --git a/packages/editor/src/ui/inspector/inspector.css b/packages/editor/src/ui/inspector/inspector.css new file mode 100644 index 0000000000..7093239912 --- /dev/null +++ b/packages/editor/src/ui/inspector/inspector.css @@ -0,0 +1,293 @@ +/* Minimal functional styles for Inspector compound components. + * This file handles layout only - no visual design. + * Import this optionally: import '@react-email/editor/styles/inspector.css'; + */ + +[data-re-inspector-field] { + display: flex; + align-items: center; + justify-content: space-between; +} + +[data-re-inspector-field] > label { + font-size: 0.75rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex-shrink: 0; + width: 40%; +} + +[data-re-inspector-select], +[data-re-inspector-number], +[data-re-inspector-color-control] { + display: inline-flex; + align-items: center; + gap: 0.1rem; + border: 1px solid var(--re-border, #e5e5e5); + border-radius: 0.25rem; + padding: 0 0.25rem; + height: 1.6rem; + outline: none; +} + +[data-re-inspector-number]:focus-within, +[data-re-inspector-color-control]:focus-within { + border-color: var(--re-text-muted, #6b6b6b); +} + +[data-re-inspector-unit], +[data-re-inspector-color-trigger] { + margin-left: 0.2rem; + width: 1rem; + height: 1rem; + aspect-ratio: 1 / 2; + border-radius: 0.25rem; + cursor: pointer; + appearance: none; + -webkit-appearance: none; +} + +[data-re-inspector-color-trigger]::-webkit-color-swatch-wrapper { + padding: 0; +} + +[data-re-inspector-color-trigger]::-webkit-color-swatch { + border: none; + border-radius: inherit; +} + +[data-re-inspector-color-trigger]::-moz-color-swatch { + border: none; + border-radius: inherit; +} + +[data-re-inspector-input], +[data-re-inspector-color-hex] { + max-width: 3.5rem; + font-size: 0.75rem; + font-family: monospace; + padding: 0.25rem 0.375rem; + color: inherit; + outline: none; +} + +[data-re-inspector-number]:has(+ [data-re-inspector-toggle-group]) + [data-re-inspector-input] { + max-width: 2.3rem; +} + +/* ---------------------------------------------------------------- + * Toggle group + * ---------------------------------------------------------------- */ + +[data-re-inspector-toggle-group] { + display: inline-flex; + border: 1px solid var(--re-border, #e5e5e5); + border-radius: 0.25rem; + overflow: hidden; +} + +[data-re-inspector-toggle-item] { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem 0.375rem; + border: none; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 0.75rem; +} + +[data-re-inspector-toggle-item] + [data-re-inspector-toggle-item] { + border-left: 1px solid var(--re-border, #e5e5e5); +} + +[data-re-inspector-toggle-item][data-active] { + background: var(--re-bg-active, #f0f0f0); +} + +/* ---------------------------------------------------------------- + * Tooltip + * ---------------------------------------------------------------- */ + +[data-re-inspector-tooltip] { + position: relative; + display: inline-flex; +} + +[data-re-inspector-tooltip-content] { + display: none; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + background: var(--re-tooltip-bg, #1a1a1a); + color: var(--re-tooltip-text, #fff); + font-size: 0.6875rem; + white-space: nowrap; + pointer-events: none; + z-index: 10; + margin-bottom: 0.25rem; +} + +[data-re-inspector-tooltip]:hover [data-re-inspector-tooltip-content] { + display: block; +} + +/* ---------------------------------------------------------------- + * Buttons + * ---------------------------------------------------------------- */ + +[data-re-inspector-button] { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + padding: 0.375rem 0.75rem; + border: 1px solid var(--re-border, #e5e5e5); + border-radius: 0.25rem; + background: transparent; + color: inherit; + font-size: 0.75rem; + cursor: pointer; +} + +[data-re-inspector-button]:hover { + background: var(--re-bg-hover, #f5f5f5); +} + +[data-re-inspector-icon-button] { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + border: none; + background: transparent; + color: inherit; + cursor: pointer; + border-radius: 0.25rem; +} + +[data-re-inspector-icon-button]:hover { + background: var(--re-bg-hover, #f5f5f5); +} + +/* ---------------------------------------------------------------- + * Label & Text + * ---------------------------------------------------------------- */ + +[data-re-inspector-label] { + font-size: 0.75rem; + color: var(--re-text-muted, #6b6b6b); + white-space: nowrap; + flex-shrink: 0; + min-width: 4rem; +} + +[data-re-inspector-text][data-color="muted"] { + color: var(--re-text-muted, #6b6b6b); +} + +/* ---------------------------------------------------------------- + * Prop row + * ---------------------------------------------------------------- */ + +[data-re-inspector-prop-row] { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.75rem; + width: 100%; +} + +/* ---------------------------------------------------------------- + * Section + * ---------------------------------------------------------------- */ + +[data-re-inspector-section] { + display: flex; + flex-direction: column; + gap: 0.5rem; + border-bottom: 1px solid var(--re-border, #e5e5e5); + padding: 1rem 0; +} + +[data-re-inspector-section]:last-child { + border-bottom: none; +} + +[data-re-inspector-section-header] { + display: flex; + align-items: center; + justify-content: space-between; +} + +[data-re-inspector-section-toggle] { + background: none; + border: none; + padding: 0; + cursor: pointer; + color: inherit; + font: inherit; + font-weight: 600; + font-size: 0.75rem; +} + +[data-re-inspector-section-body] { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* ---------------------------------------------------------------- + * Breadcrumb + * ---------------------------------------------------------------- */ + +[data-re-inspector-breadcrumb-list] { + display: flex; + align-items: center; + gap: 0.25rem; + list-style: none; + margin: 0; + padding: 0; + font-size: 0.75rem; +} + +[data-re-inspector-breadcrumb-item] { + display: flex; + align-items: center; + gap: 0.25rem; +} + +[data-re-inspector-breadcrumb-separator] { + color: var(--re-text-muted, #6b6b6b); +} + +[data-re-inspector-breadcrumb-button] { + background: none; + border: none; + padding: 0; + color: var(--re-text, #1c1c1c); + font: inherit; + font-size: 0.75rem; + cursor: default; +} + +[data-re-inspector-breadcrumb-button][data-clickable] { + cursor: pointer; +} + +[data-re-inspector-breadcrumb-button][data-clickable]:hover { + text-decoration: underline; +} + +[data-re-inspector-breadcrumb-ellipsis] { + color: var(--re-text-muted, #6b6b6b); + font-size: 0.75rem; +} diff --git a/packages/editor/src/ui/inspector/node.tsx b/packages/editor/src/ui/inspector/node.tsx new file mode 100644 index 0000000000..ab715ce6fc --- /dev/null +++ b/packages/editor/src/ui/inspector/node.tsx @@ -0,0 +1,268 @@ +'use client'; + +import { useCurrentEditor } from '@tiptap/react'; +import * as React from 'react'; +import { + stylesToCss, + useEmailTheming, +} from '../../plugins/email-theming/extension'; +import { SUPPORTED_CSS_PROPERTIES } from '../../plugins/email-theming/themes'; +import type { KnownCssProperties } from '../../plugins/email-theming/types'; +import { inlineCssToJs } from '../../utils/styles'; +import { useDocumentColors } from './hooks/use-document-colors'; +import type { FocusedNode } from './root'; +import { useInspector } from './root'; +import { AttributesSection } from './sections/attributes'; +import { BackgroundSection } from './sections/background'; +import { BorderSection } from './sections/border'; +import { ColumnSpacingSection } from './sections/column-spacing'; +import { PaddingSection } from './sections/padding'; +import { SizeSection } from './sections/size'; +import { TypographySection } from './sections/typography'; +import { resolveThemeDefaults } from './utils/resolve-theme-defaults'; +import { + customUpdateAttributes, + customUpdateStyles, +} from './utils/style-updates'; + +export interface InspectorNodeContext { + nodeType: string; + nodePos: { pos: number; inside: number }; + getStyle: (prop: KnownCssProperties) => string | number | undefined; + setStyle: (prop: KnownCssProperties, value: string | number) => void; + batchSetStyle: ( + changes: Array<{ prop: KnownCssProperties; value: string | number }>, + ) => void; + getAttr: (name: string) => unknown; + setAttr: (name: string, value: unknown) => void; + themeDefaults: Record; + presetColors: string[]; +} + +export interface InspectorNodeProps { + children?: (context: InspectorNodeContext) => React.ReactNode; +} + +export function InspectorNode({ children }: InspectorNodeProps) { + const { editor } = useCurrentEditor(); + const theming = useEmailTheming(editor); + const { target } = useInspector(); + const documentColors = useDocumentColors(editor); + + const [localAttr, setLocalAttr] = React.useState< + FocusedNode['nodeAttrs'] | null + >(null); + + const focusedNode = + typeof target === 'object' && target.nodeType !== 'body' ? target : null; + + React.useEffect(() => { + if (focusedNode) { + setLocalAttr(focusedNode.nodeAttrs); + } + }, [focusedNode]); + + if (!editor || !theming || !focusedNode) { + return null; + } + + const attrs = localAttr ?? focusedNode.nodeAttrs; + const inlineStyles = inlineCssToJs(attrs.style || ''); + + const css = stylesToCss(theming.styles, theming.theme); + const themeDefaults = resolveThemeDefaults( + focusedNode.nodeType, + attrs as Record, + css, + ); + + const mergedStyles: Record = { + ...themeDefaults, + ...inlineStyles, + }; + + const getStyle = (prop: KnownCssProperties) => { + const value = mergedStyles[prop]; + // Strip the trailing CSS unit only for numeric properties so that + // numeric inputs receive a parseable number. Non-numeric properties + // (colors, gradients, etc.) are returned verbatim — stripping `%`/`px` + // globally would corrupt values like `hsl(200, 50%, 40%)`. + const isNumericProperty = Boolean(SUPPORTED_CSS_PROPERTIES[prop]?.unit); + if (isNumericProperty && typeof value === 'string') { + return value.replace(/(px|%)$/, ''); + } + return value; + }; + + const setStyle = (prop: KnownCssProperties, value: string | number) => { + customUpdateStyles( + { + editor, + nodePos: focusedNode.nodePos, + prop, + newValue: value, + }, + setLocalAttr, + ); + }; + + const batchSetStyle = ( + changes: Array<{ prop: KnownCssProperties; value: string | number }>, + ) => { + customUpdateStyles( + { + editor, + nodePos: focusedNode.nodePos, + changes: changes.map((c) => [c.prop, c.value]), + }, + setLocalAttr, + ); + }; + + const getAttr = (name: string) => attrs[name]; + + const setAttr = (name: string, value: unknown) => { + customUpdateAttributes( + { + editor, + nodePos: focusedNode.nodePos, + prop: name, + newValue: value as string | number, + }, + setLocalAttr, + ); + }; + + const context: InspectorNodeContext = { + nodeType: focusedNode.nodeType, + nodePos: focusedNode.nodePos, + getStyle, + setStyle, + batchSetStyle, + getAttr, + setAttr, + themeDefaults, + presetColors: documentColors, + }; + + if (children) { + return <>{children(context)}; + } + + return ; +} + +interface NodeLayout { + sections: Array<{ + type: + | 'attributes' + | 'size' + | 'link' + | 'typography' + | 'padding' + | 'columnSpacing' + | 'background' + | 'border'; + }>; +} + +function getDefaultLayout(nodeType: string): NodeLayout { + switch (nodeType) { + case 'image': + return { + sections: [ + { type: 'attributes' }, + { type: 'size' }, + { type: 'link' }, + { type: 'padding' }, + { type: 'border' }, + ], + }; + case 'button': + return { + sections: [ + { type: 'link' }, + { type: 'typography' }, + { type: 'size' }, + { type: 'padding' }, + { type: 'border' }, + { type: 'background' }, + ], + }; + case 'section': + case 'div': + return { + sections: [ + { type: 'background' }, + { type: 'padding' }, + { type: 'border' }, + ], + }; + case 'codeBlock': + return { + sections: [ + { type: 'attributes' }, + { type: 'padding' }, + { type: 'border' }, + ], + }; + case 'footer': + return { + sections: [ + { type: 'typography' }, + { type: 'padding' }, + { type: 'background' }, + ], + }; + case 'twoColumns': + case 'threeColumns': + case 'fourColumns': + return { + sections: [ + { type: 'columnSpacing' }, + { type: 'typography' }, + { type: 'padding' }, + { type: 'background' }, + { type: 'border' }, + ], + }; + default: + return { + sections: [ + { type: 'typography' }, + { type: 'padding' }, + { type: 'background' }, + { type: 'border' }, + ], + }; + } +} + +function InspectorNodeDefaults({ context }: { context: InspectorNodeContext }) { + const layout = getDefaultLayout(context.nodeType); + + return ( + <> + {layout.sections.map((section) => { + switch (section.type) { + case 'attributes': + return ; + case 'size': + return ; + case 'typography': + return ; + case 'padding': + return ; + case 'columnSpacing': + return ; + case 'background': + return ; + case 'border': + return ; + default: + return null; + } + })} + + ); +} diff --git a/packages/editor/src/ui/inspector/primitives/button.tsx b/packages/editor/src/ui/inspector/primitives/button.tsx new file mode 100644 index 0000000000..8638cb154f --- /dev/null +++ b/packages/editor/src/ui/inspector/primitives/button.tsx @@ -0,0 +1,19 @@ +import type * as React from 'react'; + +export interface ButtonProps extends React.ComponentProps<'button'> { + variant?: string; +} + +export function Button({ variant, className, children, ...rest }: ButtonProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/inspector/primitives/color-input.tsx b/packages/editor/src/ui/inspector/primitives/color-input.tsx new file mode 100644 index 0000000000..b01d06c930 --- /dev/null +++ b/packages/editor/src/ui/inspector/primitives/color-input.tsx @@ -0,0 +1,31 @@ +import type * as React from 'react'; +import { normalizeHex } from '../utils/is-valid-hex-color'; + +export interface ColorInputProps { + value: string; + onChange: (value: string) => void; + className?: string; +} + +export function ColorInput({ value, onChange, className }: ColorInputProps) { + return ( + + ) => + onChange(e.target.value) + } + /> + ) => + onChange(e.target.value) + } + /> + + ); +} diff --git a/packages/editor/src/ui/inspector/primitives/icon-button.tsx b/packages/editor/src/ui/inspector/primitives/icon-button.tsx new file mode 100644 index 0000000000..99357ad5ea --- /dev/null +++ b/packages/editor/src/ui/inspector/primitives/icon-button.tsx @@ -0,0 +1,16 @@ +import type * as React from 'react'; + +export interface IconButtonProps extends React.ComponentProps<'button'> {} + +export function IconButton({ className, children, ...rest }: IconButtonProps) { + return ( + + ); +} diff --git a/packages/editor/src/ui/inspector/primitives/index.ts b/packages/editor/src/ui/inspector/primitives/index.ts new file mode 100644 index 0000000000..e6ad0c2424 --- /dev/null +++ b/packages/editor/src/ui/inspector/primitives/index.ts @@ -0,0 +1,10 @@ +export { Button } from './button'; +export { ColorInput } from './color-input'; +export { IconButton } from './icon-button'; +export { Label } from './label'; +export * as Select from './select'; +export { Text } from './text'; +export { TextField } from './text-field'; +export { Textarea } from './textarea'; +export * as ToggleGroup from './toggle-group'; +export * as Tooltip from './tooltip'; diff --git a/packages/editor/src/ui/inspector/primitives/label.tsx b/packages/editor/src/ui/inspector/primitives/label.tsx new file mode 100644 index 0000000000..fd36429687 --- /dev/null +++ b/packages/editor/src/ui/inspector/primitives/label.tsx @@ -0,0 +1,8 @@ +import type * as React from 'react'; + +export interface LabelProps extends React.ComponentProps<'label'> {} + +export function Label({ className, ...rest }: LabelProps) { + // biome-ignore lint/a11y/noLabelWithoutControl: consumer provides htmlFor + return